blob: 8c08db55bde6d6323835409f28b93b5550203e26 [file]
/*
* Copyright 2022 WebAssembly Community Group participants
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//
// Monomorphization of code based on callsite context: When we see a call, see
// if the information at the callsite can help us optimize. For example, if a
// parameter is constant, then using that constant in the called function may
// unlock a lot of improvements. We may benefit from monomorphizing in the
// following cases:
//
// * If a call provides a more refined type than the function declares for a
// parameter.
// * If a call provides a constant as a parameter.
// * If a call provides a GC allocation as a parameter. TODO
// * If a call is dropped. TODO also other stuff on the outside?
//
// We realize the benefit by creating a monomorphized (specialized/refined)
// version of the function, and call that instead. For example, if we have
//
// function foo(x) { return x + 22; }
// foo(7);
//
// then monomorphization leads to this:
//
// function foo(x) { return x + 22; } // original unmodified function
// foo_b(); // now calls foo_b
// function foo_b() { return 7 + 22; } // monomorphized, constant 7 applied
//
// This is related to inlining both conceptually and practically. Conceptually,
// one of inlining's big advantages is that we then optimize the called code
// together with the code around the call, and monomorphization does something
// similar. And, this pass does so by "reverse-inlining" content from the
// caller to the monomorphized function: the constant 7 in the example above has
// been "pulled in" from the caller into the callee. Larger amounts of code can
// be moved in that manner, both values sent to the function, and the code that
// receives it (see the mention of dropped calls, before).
//
// As this monormophization uses callsite context (the parameters, where the
// result flows to), we call it "Contextual Monomorphization." The full name is
// "Empirical Contextural Monomorphization" because we decide where to optimize
// based on a "try it and see" (empirical) approach, that measures the benefit.
// That is, we generate the monomorphized function as explained, then optimize
// that function, which contains the original code + code from the callsite
// context that we pulled in. If the optimizer manages to improve that combined
// code in a useful way then we apply the optimization, and if not then we undo.
//
// The empirical approach significantly reduces the need for heuristics. For
// example, rather than have a heuristic for "see if a constant parameter flows
// into a conditional branch," we simply run the optimizer and let it optimize
// that case. All other cases handled by the optimizer work as well, without
// needing to specify them as heuristics, so this gets smarter as the optimizer
// does.
//
// Aside from the main version of this pass there is also a variant useful for
// testing that always monomorphizes non-trivial callsites, without checking if
// the optimizer can help or not (that makes writing testcases simpler).
//
// TODO: When we optimize we could run multiple cycles: A calls B calls C might
// end up with the refined+optimized B now having refined types in its
// call to C, which it did not have before. This is in fact the expected
// pattern of incremental monomorphization. Doing it in the pass could be
// more efficient as later cycles can focus only on what was just
// optimized and changed. Also, operating on functions just modified would
// help the case of A calls B and we end up optimizing A after we consider
// A->B, and the optimized version sends more refined types to B, which
// could unlock more potential.
// TODO: We could sort the functions so that we start from root functions and
// end on leaves. That would make it more likely for a single iteration to
// do more work, as if A->B->C then we'd do A->B and optimize B and only
// then look at B->C.
// TODO: If this is too slow, we could "group" things, for example we could
// compute the LUB of a bunch of calls to a target and then investigate
// that one case and use it in all those callers.
// TODO: Not just direct calls? But updating vtables is complex.
//
#include "ir/cost.h"
#include "ir/find_all.h"
#include "ir/manipulation.h"
#include "ir/module-utils.h"
#include "ir/names.h"
#include "ir/return-utils.h"
#include "ir/type-updating.h"
#include "ir/utils.h"
#include "pass.h"
#include "support/hash.h"
#include "wasm-type.h"
#include "wasm.h"
namespace wasm {
namespace {
// Core information about a call: the call itself, and if it is dropped, the
// drop.
struct CallInfo {
Call* call;
// Store a reference to the drop's pointer so that we can replace it, as when
// we optimize a dropped call we need to replace (drop (call)) with (call).
// Or, if the call is not dropped, this is nullptr.
Expression** drop;
};
// Finds the calls and whether each one of them is dropped.
struct CallFinder : public PostWalker<CallFinder> {
std::vector<CallInfo> infos;
void visitCall(Call* curr) {
// Add the call as not having a drop, and update the drop later if we are.
infos.push_back(CallInfo{curr, nullptr});
}
void visitDrop(Drop* curr) {
if (curr->value->is<Call>()) {
// The call we just added to |infos| is dropped.
assert(!infos.empty());
auto& back = infos.back();
assert(back.call == curr->value);
back.drop = getCurrentPointer();
}
}
};
// Relevant information about a callsite for purposes of monomorphization.
struct CallContext {
// The operands of the call, processed to leave the parts that make sense to
// keep in the context. That is, the operands of the CallContext are the exact
// code that will appear at the start of the monomorphized function. For
// example:
//
// (call $foo
// (i32.const 10)
// (..something complicated..)
// )
// (func $foo (param $int i32) (param $complex f64)
// ..
//
// The context operands are
//
// [
// (i32.const 10) ;; Unchanged: this can be pulled into the called
// ;; function, and removed from the caller side.
// (local.get $0) ;; The complicated child cannot; keep it as a value
// ;; sent from the caller, which we will local.get.
// ]
//
// Both the const and the local.get are simply used in the monomorphized
// function, like this:
//
// (func $foo-monomorphized (param $0 ..)
// (..local defs..)
// ;; Apply the first operand, which was pulled into here.
// (local.set $int
// (i32.const 10)
// )
// ;; Read the second, which remains a parameter to the function.
// (local.set $complex
// (local.get $0)
// )
// ;; The original body.
// ..
//
// The $int param is no longer a parameter, and it is set in a local at the
// top: we have "reverse-inlined" code from the calling function into the
// caller, pulling the constant 10 into here. The second parameter cannot be
// pulled in, so we must still send it, but we still have a local.set there to
// copy it into a local (this does not matter in this case, but does if the
// sent value is more refined; always using a local.set is simpler and more
// regular).
std::vector<Expression*> operands;
// Whether the call is dropped. TODO
bool dropped;
bool operator==(const CallContext& other) const {
if (dropped != other.dropped) {
return false;
}
// We consider logically equivalent expressions as equal (rather than raw
// pointers), so that contexts with functionally identical shape are
// treated the same.
if (operands.size() != other.operands.size()) {
return false;
}
for (Index i = 0; i < operands.size(); i++) {
if (!ExpressionAnalyzer::equal(operands[i], other.operands[i])) {
return false;
}
}
return true;
}
bool operator!=(const CallContext& other) const { return !(*this == other); }
// Build the context from a given call. This builds up the context operands as
// as explained in the comments above, and updates the call to send any
// remaining values by updating |newOperands| (for example, if all the values
// sent are constants, then |newOperands| will end up empty, as we have
// nothing left to send).
void buildFromCall(CallInfo& info,
std::vector<Expression*>& newOperands,
Module& wasm) {
Builder builder(wasm);
for (auto* operand : info.call->operands) {
// Process the operand. This is a copy operation, as we are trying to move
// (copy) code from the callsite into the called function. When we find we
// can copy then we do so, and when we cannot that value remains as a
// value sent from the call.
operands.push_back(ExpressionManipulator::flexibleCopy(
operand, wasm, [&](Expression* child) -> Expression* {
if (canBeMovedIntoContext(child)) {
// This can be moved, great: let the copy happen.
return nullptr;
}
// This cannot be moved, so we stop here: this is a value that is sent
// into the monomorphized function. It is a new operand in the call,
// and in the context operands it is a local.get, that reads that
// value.
auto paramIndex = newOperands.size();
newOperands.push_back(child);
// TODO: If one operand is a tee and another a get, we could actually
// reuse the local, effectively showing the monomorphized
// function that the values are the same. (But then the checks
// later down to is<LocalGet> would need to check index too.)
return builder.makeLocalGet(paramIndex, child->type);
}));
}
dropped = !!info.drop;
}
// Checks whether an expression can be moved into the context.
bool canBeMovedIntoContext(Expression* curr) {
// Constant numbers, funcs, strings, etc. can all be copied, so it is ok to
// add them to the context.
// TODO: Allow global.get as well, and anything else that is purely
// copyable.
return Properties::isSingleConstantExpression(curr);
}
// Check if a context is trivial relative to a call, that is, the context
// contains no information that can allow optimization at all. Such trivial
// contexts can be dismissed early.
bool isTrivial(Call* call, Module& wasm) {
// Dropped contexts are not trivial.
if (dropped) {
return false;
}
// The context must match the call for us to compare them.
assert(operands.size() == call->operands.size());
// If an operand is not simply passed through, then we are not trivial.
auto callParams = wasm.getFunction(call->target)->getParams();
for (Index i = 0; i < operands.size(); i++) {
// A local.get of the same type implies we just pass through the value.
// Anything else is not trivial.
if (!operands[i]->is<LocalGet>() || operands[i]->type != callParams[i]) {
return false;
}
}
// We found nothing interesting, so this is trivial.
return true;
}
};
} // anonymous namespace
} // namespace wasm
namespace std {
template<> struct hash<wasm::CallContext> {
size_t operator()(const wasm::CallContext& info) const {
size_t digest = hash<bool>{}(info.dropped);
wasm::rehash(digest, info.operands.size());
for (auto* operand : info.operands) {
wasm::hash_combine(digest, wasm::ExpressionAnalyzer::hash(operand));
}
return digest;
}
};
// Useful for debugging.
[[maybe_unused]] void dump(std::ostream& o, wasm::CallContext& context) {
o << "CallContext{\n";
for (auto* operand : context.operands) {
o << " " << *operand << '\n';
}
if (context.dropped) {
o << " dropped\n";
}
o << "}\n";
}
} // namespace std
namespace wasm {
namespace {
struct Monomorphize : public Pass {
// If set, we run some opts to see if monomorphization helps, and skip cases
// where we do not help out.
bool onlyWhenHelpful;
Monomorphize(bool onlyWhenHelpful) : onlyWhenHelpful(onlyWhenHelpful) {}
void run(Module* module) override {
// TODO: parallelize, see comments below
// Find all the return-calling functions. We cannot remove their returns
// (because turning a return call into a normal call may break the program
// by using more stack).
auto returnCallersMap = ReturnUtils::findReturnCallers(*module);
// Note the list of all functions. We'll be adding more, and do not want to
// operate on those.
std::vector<Name> funcNames;
ModuleUtils::iterDefinedFunctions(
*module, [&](Function* func) { funcNames.push_back(func->name); });
// Find the calls in each function and optimize where we can, changing them
// to call the monomorphized targets.
for (auto name : funcNames) {
auto* func = module->getFunction(name);
CallFinder callFinder;
callFinder.walk(func->body);
for (auto& info : callFinder.infos) {
if (info.call->type == Type::unreachable) {
// Ignore unreachable code.
// TODO: return_call?
continue;
}
if (info.call->target == name) {
// Avoid recursion, which adds some complexity (as we'd be modifying
// ourselves if we apply optimizations).
continue;
}
// If the target function does a return call, then as noted earlier we
// cannot remove its returns, so do not consider the drop as part of the
// context in such cases (as if we reverse-inlined the drop into the
// target then we'd be removing the returns).
if (returnCallersMap[module->getFunction(info.call->target)]) {
info.drop = nullptr;
}
processCall(info, *module);
}
}
}
// Try to optimize a call.
void processCall(CallInfo& info, Module& wasm) {
auto* call = info.call;
auto target = call->target;
auto* func = wasm.getFunction(target);
if (func->imported()) {
// Nothing to do since this calls outside of the module.
return;
}
// TODO: ignore calls with unreachable operands for simplicty
// Compute the call context, and the new operands that the call would send
// if we use that context.
CallContext context;
std::vector<Expression*> newOperands;
context.buildFromCall(info, newOperands, wasm);
// See if we've already evaluated this function + call context. If so, then
// we've memoized the result.
auto iter = funcContextMap.find({target, context});
if (iter != funcContextMap.end()) {
auto newTarget = iter->second;
if (newTarget != target) {
// We saw benefit to optimizing this case. Apply that.
updateCall(info, newTarget, newOperands, wasm);
}
return;
}
// This is the first time we see this situation. First, check if the context
// is trivial and has no opportunities for optimization.
if (context.isTrivial(call, wasm)) {
// Memoize the failure, and stop.
funcContextMap[{target, context}] = target;
return;
}
// Create the monomorphized function that includes the call context.
std::unique_ptr<Function> monoFunc =
makeMonoFunctionWithContext(func, context, wasm);
// Decide whether it is worth using the monomorphized function.
auto worthwhile = true;
if (onlyWhenHelpful) {
// Run the optimizer on both functions, hopefully just enough to see if
// there is a benefit to the context. We optimize both to avoid confusion
// from the function benefiting from simply running another cycle of
// optimization.
//
// Note that we do *not* discard the optimizations to the original
// function if we decide not to optimize. We've already done them, and the
// function is improved, so we may as well keep them.
//
// TODO: Atm this can be done many times per function as it is once per
// function and per set of types sent to it. Perhaps have some
// total limit to avoid slow runtimes.
// TODO: We can end up optimizing |func| more than once. It may be
// different each time if the previous optimization helped, but
// often it will be identical. We could save the original version
// and use that as the starting point here (and cache the optimized
// version), but then we'd be throwing away optimization results. Or
// we could see if later optimizations do not further decrease the
// cost, and if so, use a cached value for the cost on such
// "already maximally optimized" functions. The former approach is
// more amenable to parallelization, as it avoids path dependence -
// the other approaches are deterministic but they depend on the
// order in which we see things. But it does require saving a copy
// of the function, which uses memory, which is avoided if we just
// keep optimizing from the current contents as we go. It's not
// obvious which approach is best here.
doOpts(func);
doOpts(monoFunc.get());
auto costBefore = CostAnalyzer(func->body).cost;
auto costAfter = CostAnalyzer(monoFunc->body).cost;
// TODO: We should probably only accept improvements above some minimum,
// to avoid optimizing cases where we duplicate a huge function but
// only optimize a tiny part of it compared to the original.
if (costAfter >= costBefore) {
worthwhile = false;
}
}
// Memoize what we decided to call here.
funcContextMap[{target, context}] = worthwhile ? monoFunc->name : target;
if (worthwhile) {
// We are using the monomorphized function, so update the call and add it
// to the module.
updateCall(info, monoFunc->name, newOperands, wasm);
wasm.addFunction(std::move(monoFunc));
}
}
// Create a monomorphized function from the original + the call context. It
// may have different parameters, results, and may include parts of the call
// context.
std::unique_ptr<Function> makeMonoFunctionWithContext(
Function* func, const CallContext& context, Module& wasm) {
// The context has an operand for each one in the old function, each of
// which may contain reverse-inlined content. A mismatch here means we did
// not build the context right, or are using it with the wrong function.
assert(context.operands.size() == func->getNumParams());
// Pick a new name.
auto newName = Names::getValidFunctionName(wasm, func->name);
// Copy the function as the base for the new one.
auto newFunc = ModuleUtils::copyFunctionWithoutAdd(func, wasm, newName);
// Generate the new signature, and apply it to the new function.
std::vector<Type> newParams;
for (auto* operand : context.operands) {
// A local.get is a value that arrives in a parameter. Anything else is
// something that we are reverse-inlining into the function, so we don't
// need a param for it.
if (operand->is<LocalGet>()) {
newParams.push_back(operand->type);
}
}
// If we were dropped then we are pulling the drop into the monomorphized
// function, which means we return nothing.
auto newResults = context.dropped ? Type::none : func->getResults();
newFunc->type = Signature(Type(newParams), newResults);
// We must update local indexes: the new function has a potentially
// different number of parameters, and parameters are at the very bottom of
// the local index space. We are also replacing old params with vars. To
// track this, map each old index to the new one.
std::unordered_map<Index, Index> mappedLocals;
auto newParamsMinusOld =
newFunc->getParams().size() - func->getParams().size();
for (Index i = 0; i < func->getNumLocals(); i++) {
if (func->isParam(i)) {
// Old params become new vars inside the function. Below we'll copy the
// proper values into these vars.
// TODO: We could avoid a var + copy when it is trivial (atm we rely on
// optimizations to remove it).
auto local = Builder::addVar(newFunc.get(), func->getLocalType(i));
mappedLocals[i] = local;
} else {
// This is a var. The only thing to adjust here is that the parameters
// are changing.
mappedLocals[i] = i + newParamsMinusOld;
}
}
// Copy over local names to help debugging.
if (!func->localNames.empty()) {
newFunc->localNames.clear();
newFunc->localIndices.clear();
for (Index i = 0; i < func->getNumLocals(); i++) {
auto oldName = func->getLocalNameOrDefault(i);
if (oldName.isNull()) {
continue;
}
auto newIndex = mappedLocals[i];
auto newName = Names::getValidLocalName(*newFunc.get(), oldName);
newFunc->localNames[newIndex] = newName;
newFunc->localIndices[newName] = newIndex;
}
};
Builder builder(wasm);
// Surrounding the main body is the reverse-inlined content from the call
// context, like this:
//
// (func $monomorphized
// (..reverse-inlined parameter..)
// (..old body..)
// )
//
// For example, if a function that simply returns its input is called with a
// constant parameter, it will end up like this:
//
// (func $monomorphized
// (local $param i32)
// (local.set $param (i32.const 42)) ;; reverse-inlined parameter
// (local.get $param) ;; copied old body
// )
//
// We need to add such an local.set in the prelude of the function for each
// operand in the context.
std::vector<Expression*> pre;
for (Index i = 0; i < context.operands.size(); i++) {
auto* operand = context.operands[i];
// Write the context operand (the reverse-inlined content) to the local
// we've allocated for this.
auto local = mappedLocals.at(i);
auto* value = ExpressionManipulator::copy(operand, wasm);
pre.push_back(builder.makeLocalSet(local, value));
}
// Map locals.
struct LocalUpdater : public PostWalker<LocalUpdater> {
const std::unordered_map<Index, Index>& mappedLocals;
LocalUpdater(const std::unordered_map<Index, Index>& mappedLocals)
: mappedLocals(mappedLocals) {}
void visitLocalGet(LocalGet* curr) { updateIndex(curr->index); }
void visitLocalSet(LocalSet* curr) { updateIndex(curr->index); }
void updateIndex(Index& index) {
auto iter = mappedLocals.find(index);
assert(iter != mappedLocals.end());
index = iter->second;
}
} localUpdater(mappedLocals);
localUpdater.walk(newFunc->body);
if (!pre.empty()) {
// Add the block after the prelude.
pre.push_back(newFunc->body);
newFunc->body = builder.makeBlock(pre);
}
if (context.dropped) {
ReturnUtils::removeReturns(newFunc.get(), wasm);
}
return newFunc;
}
// Given a call and a new target it should be calling, apply that new target,
// including updating the operands and handling dropping.
void updateCall(const CallInfo& info,
Name newTarget,
const std::vector<Expression*>& newOperands,
Module& wasm) {
info.call->target = newTarget;
info.call->operands.set(newOperands);
if (info.drop) {
// Replace (drop (call)) with (call), that is, replace the drop with the
// (updated) call which now has type none. Note we should have handled
// unreachability before getting here.
assert(info.call->type != Type::unreachable);
info.call->type = Type::none;
*info.drop = info.call;
}
}
// Run some function-level optimizations on a function. Ideally we would run a
// minimal amount of optimizations here, but we do want to give the optimizer
// as much of a chance to work as possible, so for now do all of -O3 (in
// particular, we really need to run --precompute-propagate so constants are
// applied in the optimized function).
// TODO: Perhaps run -O2 or even -O1 if the function is large (or has many
// locals, etc.), to ensure linear time, but we could miss out.
void doOpts(Function* func) {
PassRunner runner(getPassRunner());
runner.options.optimizeLevel = 3;
runner.addDefaultFunctionOptimizationPasses();
runner.setIsNested(true);
runner.runOnFunction(func);
}
// Maps [func name, call info] to the name of a new function which is a
// monomorphization of that function, specialized to that call info.
//
// Note that this can contain funcContextMap{A, ...} = A, that is, that maps
// a function name to itself. That indicates we found no benefit from
// monomorphizing with that context, and saves us from computing it again
// later on.
std::unordered_map<std::pair<Name, CallContext>, Name> funcContextMap;
};
} // anonymous namespace
Pass* createMonomorphizePass() { return new Monomorphize(true); }
Pass* createMonomorphizeAlwaysPass() { return new Monomorphize(false); }
} // namespace wasm