blob: bf7839dfa48fe98cb01a3ef90d17f22098bcc382 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
/*!
* \file src/relay/transforms/device_planner.cc
* \brief Determines a unique \p VirtualDevice to hold the result of every Relay sub-expression.
* This pass can be run multiple times, and can be run both before and after lowering.
*
* We say a Relay expression E is 'on device D' if the result of executing E is stored on D.
* We represent D by an \p VirtualDevice, which means we can track anywhere from an arbitrary device
* of some \p DLDeviceType to a specific memory scope on a specific (virtual) \p Device who's
* code is compiled with a specific \p Target.
*
* Note that 'stored on device D' is almost but not quite the same as 'executes on device D',
* see below.
*
* This pass works by collecting and solving device constraints, using defaulting heuristics to
* resolve any remaining undetermined devices, and encoding the results on the output in a form
* that's reasonably friendly to downstream passes.
*
* Specific \p VirtualDevices flow into the constraints from five places:
* - Existing "device_copy" CallNodes (with a \p DeviceCopyAttrs attribute) specify a
* 'src_virtual_device' and 'dst_virtual_device' \p VirtualDevice. Those constrain the argument
* and context of the call respectively. It is ok if source and destination devices are the same,
* such no-op copies will be removed after accounting for the device preference.
* - Existing "on_device" CallNodes (with a \p OnDeviceAttrs attribute) specify an
* 'virtual_device', which constrains the argument of the call, but (usually, see below) leaves the
* context unconstrained. These are called 'annotations' in the rest of the code, have no
* operational significance by themselves, but may trigger the insertion of a new "device_copy" call
* by this pass. In two situations the result of an "on_device" CallNode may also be constrained to
* the given 'virtual_device':
* - The "on_device" call occurs at the top-level of a function body, or occurs as an
* immediately let-bound expression. In this situation the extra degree of freedom in
* the function result and let-binding leads to surprising device copies, so we simply
* force the function result or let-bound variable to the given device.
* - The \p OnDeviceAttrs has an \p is_fixed field of \p true, which indicates we inserted
* it ourselves during an earlier invocation of this pass. This helps make this pass
* idempotent.
* - Some special operators require their arguments or results to be on the 'host' (typcially
* a CPU) \p VirtualDevice, see below.
* - Any \p PrimFuncs in the \p IRModule (if \p LowerTEPass has already run) may constrain their
* argument buffers to have a specific memory scope, which is part of \p VirtualDevice.
* - Annotations left over from a previous run of this pass, such as 'param_virtual_devices' and
* 'result_virtual_device' function attributes we introduce below. This is so the pass is
* idempotent and can be re-run to flow additional memory scope constraints.
*
* We proceed in four phases:
*
* Phase 0
* -------
* We rewrite the programs to handle some special cases:
* - "on_device" calls at the top-level of function or immediately let-bound are rewritten
* to have \code is_fixed=true \endcode.
* - We wish to treat \code on_device(expr, device_type=d).0 \endcode as if it were written
* \code on_device(expr.0, device_type_d) \endcode. I.e. we prefer to copy the projection from
* the tuple rather than project from a copy of the tuple. We'll do this by rewriting.
* - We are prepared to insert device_copies on the arguments and result of calls to PrimFuncs,
* on the assumption a) we already ran PlanDevices before lowering so we are not allowing
* any new cross-device copies, but b) after lowering we may have new memory scope constraits
* to deal with.
*
* Phase 1
* -------
* We flow constraints from the "on_device" and "device_copy" calls, PrimFunc buffer memory scopes,
* and some special ops, to all other Relay sub-expressions.
*
* For a primitive such as \code add(e1, e2) \endcode all arguments and results must be on the
* same device. However each call site can use a different device. In other words primitives are
* 'device polymorphic' since we compile and execute them for each required device. ADT constructors
* are similarly polymorphic, but require all constructor args to be on the same device.
*
* For most Relay expressions the device for the overall expression is the same as the device
* for its sub-expressions. E.g. each field of a tuple must be on the same device as the tuple
* itself, the condition and arms of an \p if must all be on the same device as the overall \p if,
* and so on.
*
* Some special ops (or 'dialects') are handled:
* - Relay supports computing the shape of tensors and operators at runtime using "shape_of"
* and "reshape_tensor". Shapes must only be held on the CPU, but the tensors they describe
* may reside on any device.
* - Explicit memory allocation is done using the "alloc_storage" and "alloc_tensor". Again
* shapes reside on the CPU, but the allocated tensors may reside on any device.
*
* Two Relay expression have special handling:
* - For \code let x = e1; e2 \endcode the result of \p e2 must be on the same device as the
* overall let. However the result of \p e1 may be on a different device.
* - For a function \code fn(x, y) { body } \endcode the result of the function must be on the
* same device as \p body. However parameters \p x and \p may be on different devices, even
* different from each other. Every call to the function must use the same choice of parameter
* and result devices -- there is no 'device polymorphism' for Relay functions.
*
* Currently \p PrimFuncs and external functions do not carry over their parameter and result
* devices from their original Relay Function representations. However we know all calls to those
* functions are device-consistent, thus no information is lost.
*
* Phase 2
* -------
* After flowing constraints we apply some defaulting heuristics (using a global default \p
* VirtualDevice) to fix the device for any as-yet unconstrained sub-expressions.
* - Unconstrained function result devices default to the global default device.
* - Unconstrained function parameters devices default to the device for the function result.
* - Unconstrained let-bound expression devices default to the device for the overall let.
* TODO(mbs): These are very simple minded heuristics, and ultimately we'd like to treat the
* assignment of the remaining unconstrained sub-expressions as an optimiziation problem in itself.
* This requires a formal notion of 'choicepoint' inside the compiler which can integrate with
* automation.
*
* Phase 3
* -------
* Finally, the result of this analysis is reified into the result as:
* - Additional "param_virtual_devices" (an \p Array<VirtualDevice>) and "result_virtual_device"
* (an \p VirtualDevice) attributes for every function (both top-level and local). These describe
* the devices for the function's parameters and the result.
* - Additional "device_copy" CallNodes where a copy is required in order to respect the
* intent of the original "on_device" CallNodes.
* - Additional "on_device" CallNodes where the device type of an expression is not trivially
* implied by the lexically enclosing "on_device" CallNode or function attribute. In practice
* this means "on_device" CallNodes may appear in two places:
* - On let-bound expressions. It is tempting to elide the "on_device" if the let-bound value
* has the same device as the overall let expression. However this would mean passes which
* inline let-bound values, such as FoldConstant and DeadCodeElimination, would need to us
* a DeviceAware visitor which in turn requires the expression to be in ANF to avoid
* deep recursion. To minimize disruption we always include the "on_device" so that it
* can follow the inline.
* - On a call argument if its device differs from the call result. In particular, the
* argument to a "device_copy" call will always be wrapped in an "on_device". (That may
* seem pedantic but simplifies downstream handling.)
* However since we make it easy to track devices for variables we never wrap an "on_device"
* around a var or global var. These uses of "on_device" imply both the argument and result are
* on the same device. We signal this by setting the 'is_fixed' OnDeviceAttrs field to true,
* which helps make this pass idempotent.
* - The buffer maps for called PrimFuncs are updated to capture memory scopes.
*
* Helper visitors (in device_aware_visitors.h) can be used by downstream transforms to recover
* the device for any expression for their own use, e.g. during memory planning. All downstream
* passes must preserve the lexical scoping of the "on_device" CallNodes. E.g. conversion
* to ANF must respect the lexical scoping convention:
* \code
* f(on_device(g(h(a, b), c), virtual_device=CPU))
* ==>
* let %x0 = on_device(h(a, b), virtual_device=CPU)
* let %x1 = on_device(g(%x0), virtual_device=CPU)
* f(on_device(%x1, virtual_device=CPU))
* \endcode
*
* This pass can be run before FuseOps so that it can use device-specific fusion rules.
*
* 'Stored on' vs 'Executes on'
* ----------------------------
* Obviously for a primitive call \code add(x, y) \endcode we can execute the primitive on the
* same device as will hold its result. Thus 'executes on' is the same as 'stored on' for
* primitives.
*
* But what about for arbitrary Relay expressions? Most backends (interpreter, graph, VM) are
* implicitly executed on the 'host' CPU, with only primitive evaluation handed off to specific
* devices, thus the notion of 'executes on' is mute. AOT backends on the other hand need to
* know exactly which device (possibly one of a number of available 'CPU'-like devices) is
* responsible for execution. Currently that's handled independently by the \p AnnotateTargets
* pass, but we'd like to fold that into device planning here to ensure everything is consistent.
*
* Obviously since tensors are passed-by-pointer it's quite possible to execute a Relay
* expression (eg an \p if expression) on one device even though the tensor data resides on
* another. But for AOT that flexibility seems excessive. So we'd like to just take 'executes on'
* to be 'stored on' exactly. In particular, for a Relay function, we'd like to be able to just
* compile the function body for the function's result device.
*
* This works after conversion to ANF provided the compilation for a let expression is prepared
* to make a cross-device call. However we leave it to a downstream transformation to heuristically
* minimize cross-device calls by moving device copies out of functions. E.g.:
* \code
* def @f() { // execute on CPU
* let x = on_device(...GPU computation..., virtual_device=GPU);
* device_copy(...GPU computation..., src_dev_type=GPU, dst_dev_type=CPU)
* }
* def @main() {
* ... call @f() on CPU ...
* }
* \endcode
* could be rewritten to:
* \code
* def @f() { // execute on GPU
* let x = ...GPU computation...;
* ...GPU computation...
* }
* def @main() {
* let x = device_copy(@f(), src_dev_type=GPU, dst_dev_type=CPU)
* ... use x on CPU ...
* }
* \endcode
*
* Higher-order shenanigans
* ------------------------
* Relay is a 'mostly' higher-order language -- we can let-bind functions, pass functions
* as arguments (even anonymous functions), return functions, evaluate conditional expressions
* over functions, and so on. We handle this during constraint solving using the domain:
* \code
* D ::= <specific device type> -- first-order
* | fn(D,...,D):D -- higher-order
* \endcode
* In this way we can determine the device for all function parameters and results. E.g. for
* \code
* let f = fn(x, y) { ... }
* let g = fn(f, z) { f(z, z) }
* g(f, on_device(..., virtual_device=CPU))
* \endcode
* the parameters \p x and \p y will be on the CPU.
*
* But now look closely at the call \code e1(e2, e3) \endcode. We know \p e1 must evaluate to a
* function. Our analysis must guarantee that the function's parameters and result devices are
* consistent for \p e2, \p e3, and the context of the call. But:
* - Which device holds the closure result of evaluating \p e1 ?
* - If \p e2 is of function type, what does that mean when we say every function parameter
* is on a device?
* - If \p e1 returns a function, what does that mean when we say every function result is
* on a device?
*
* Since higher-order aspects are later compiled away (by 'defunctionalization'
* aka 'firstification') we'd prefer not to have to answer any of those questions. In particular,
* we really don't want our domain \p D to allow for yet another device for the function closure.
* So we'll just force the 'device for a function' to be the same as the device for the function's
* result using the notion of the 'result domain' for a domain:
* \code
* result_domain(<specific device type>) = <specific device type>
* result_domain(fn(D1,...,Dn):Dr) = result_domain(Dr)
* \endcode
*
* Similarly the domain does not have entries for tuples, references, or ADTs. Whenever the
* analysis encounters a function inside one of those it simply forces all argument and result
* devices for the function to match the device for the first-order expression. For example,
* if the tuple \code (fn(x, y) { ... }, 3) \endcode is on the GPU then the inner function
* parameters and result must similarly be on the GPU.
*
* -------
* | AOR | This pass supports all of Relay.
* -------
* ^
* |
* `-- Mark's stamp of completeness :-)
*
* TODO(mbs): Proper diagnostics for unification failure using spans.
* TODO(mbs): We may want some 'device polymorphism' for Relay functions. Eg it's ok for the
* function to be called with params/result on different (virtual) device ids provided the target
* and memory scopes are consistent.
*/
#include <tvm/ir/transform.h>
#include <tvm/relay/analysis.h>
#include <tvm/relay/attrs/annotation.h>
#include <tvm/relay/attrs/device_copy.h>
#include <tvm/relay/attrs/memory.h>
#include <tvm/relay/expr_functor.h>
#include <tvm/relay/op.h>
#include <tvm/relay/pattern_functor.h>
#include <tvm/relay/transform.h>
#include <tvm/relay/type.h>
#include <tvm/runtime/c_runtime_api.h>
#include <tvm/runtime/object.h>
#include <tvm/tir/function.h>
#include <tvm/tir/stmt_functor.h>
#include <unordered_map>
#include "../../tir/analysis/device_constraint_utils.h"
#include "../op/annotation/annotation.h"
#include "../op/memory/device_copy.h"
#include "../op/memory/on_device.h"
#include "./device_domains.h"
namespace tvm {
namespace relay {
namespace transform {
namespace {
/* =============== Phase 0 =============== */
/*!
* \brief Rewrites "on_device" calls to handle some special cases.
*
* - Don't let the device for %x remain unconstrained:
* \code
* let %x = on_device(e, virtual_device=d)
* ==> let %x = on_device(e, virtual_device=d, constraint=kBoth)
* \endcode
*
* - Don't let the function result remain unconstrained:
* \code
* fn(%x) { on_device(e, virtual_device=d) }
* ==> fn(%x) { on_device(e, virtual_device=d, constraint=kBoth)
* \endcode
*
* - Project-then-copy rather than copy-then-project:
* \code
* on_device(e).0
* ==> on_device(e.0)
* \endcode
*
* - Be prepared to copy arguments and results on primitive call boundaries in case memory
* scopes don't line up. We'll use the 'fully unconstrained' version of on_device so that
* we can allow for a device_copy without knowing the specific device for the arguments.
* \code
* call_lowered(@prim, (a, b))
* ==> copy_ok(call_lowered(@prim, (copy_ok(a), copy_ok(b))))
* where
* copy_ok(x) = on_device(x, virtual_device=VirtualDevice::FullyUnconstrained,
* constrain_body=False, constrain_result=False)
* \endcode
*/
class RewriteOnDevices : public ExprMutator {
public:
explicit RewriteOnDevices(IRModule mod) : mod_(std::move(mod)) {}
private:
Expr VisitExpr_(const TupleGetItemNode* tuple_get_item_node) final {
Expr tuple = VisitExpr(tuple_get_item_node->tuple);
OnDeviceProps props = GetOnDeviceProps(tuple);
Expr tuple_get_item = WithFields(GetRef<TupleGetItem>(tuple_get_item_node), tuple);
if (props.body.defined() && props.is_normal()) {
VLOG(2) << "wrapping tuple get item:" << std::endl
<< PrettyPrint(GetRef<TupleGetItem>(tuple_get_item_node)) << std::endl
<< "with \"on_device\" for VirtualDevice " << props.virtual_device;
return OnDeviceWithProps(tuple_get_item, props);
} else {
return tuple_get_item;
}
}
Expr VisitExpr_(const LetNode* let_node) final {
auto expr = GetRef<Expr>(let_node);
std::vector<std::tuple<Let, Expr>> bindings;
while (const auto* inner_let_node = expr.as<LetNode>()) {
Let inner_let = GetRef<Let>(inner_let_node);
Expr value = VisitExpr(inner_let_node->value);
OnDeviceProps props = GetOnDeviceProps(value);
if (props.body.defined() && props.is_normal()) {
VLOG(2) << "revising let-bound expression of let:" << std::endl
<< PrettyPrint(expr) << std::endl
<< "to be fixed to VirtualDevice " << props.virtual_device;
value = MaybeOnDeviceFixed(props.body, props.virtual_device);
}
bindings.emplace_back(inner_let, value);
expr = inner_let_node->body;
}
expr = VisitExpr(expr);
for (auto itr = bindings.rbegin(); itr != bindings.rend(); ++itr) {
expr = WithFields(/*let=*/std::get<0>(*itr), /*opt_var=*/{},
/*opt_value=*/std::get<1>(*itr), /*opt_body=*/expr);
}
return expr;
}
Expr VisitExpr_(const FunctionNode* function_node) final {
Expr body = VisitExpr(function_node->body);
OnDeviceProps props = GetOnDeviceProps(body);
if (props.body.defined() && props.is_normal()) {
VLOG(2) << "revising body of function:" << std::endl
<< PrettyPrint(GetRef<Function>(function_node)) << std::endl
<< "to be fixed to VirtualDevice " << props.virtual_device;
body = MaybeOnDeviceFixed(props.body, props.virtual_device);
}
return WithFields(GetRef<Function>(function_node), function_node->params, body);
}
Expr VisitExpr_(const CallNode* call_node) final {
CallLoweredProps props = GetCallLoweredProps(call_node);
if (props.lowered_func.defined()) {
BaseFunc base_func = mod_->Lookup(props.lowered_func);
if (base_func.as<tir::PrimFuncNode>()) {
VLOG(2) << "allowing device_copy on PrimFunc arguments and result";
Array<Expr> new_args;
new_args.reserve(props.arguments.size());
for (const auto& arg : props.arguments) {
Expr new_arg = VisitExpr(arg);
new_args.push_back(OnDeviceCopyOk(std::move(new_arg)));
}
Call new_call = CallLowered(std::move(props.lowered_func), std::move(new_args), props.attrs,
call_node->span);
return OnDeviceCopyOk(std::move(new_call));
}
}
return ExprMutator::VisitExpr_(call_node);
}
/*! \brief Module we are rewriting, so we can lookup global definitions. */
IRModule mod_;
};
/* =============== Phase 1 =============== */
/*
* \brief Collects the system of device constraints for all sub-expressions in a module.
* It is possible some devices remain free and will need to be defaulted by \p DeviceDefaulter.
*
* Eg from \code add(%x, %y) \endcode we know \p %x and \p %y must be on the same device. Later,
* from \code on_device(%x, virtual_device=d) \endcode we know \p %x must be on device \p d, and
* thus so must \p %y.
*
* Constraints can flow in interesting ways. E.g. in:
* \code
* let %f = fn(%x, %y) { add(%x, on_device(%y, virtual_device=d)) }
* let %g = fn(%f, %x, %y) { %f(%x, %y) }
* %g(%f, %a, %b)
* \endcode
* we discover \p %b must be on device \p d.
*/
class DeviceAnalyzer : public MixedModeVisitor {
public:
DeviceAnalyzer(IRModule mod, CompilationConfig config)
: mod_(std::move(mod)), domains_(std::make_unique<DeviceDomains>(std::move(config))) {}
/*!
* \brief Returns the expression-to-device-domain map for all expressions in all the global
* function definitions in the module. Expressions may have free domains, these will be resolved
* by \p DeviceDefaulter below.
*/
std::unique_ptr<DeviceDomains> Analyze() {
VLOG_CONTEXT << "DeviceAnalyzer";
for (const auto& kv : mod_->functions) {
// The global variable and what it is bound to must obviously agree on domain.
if (const auto* function_node = AsOptimizableFunctionNode(kv.second)) {
VLOG(2) << "collecting constraints from Relay Function '" << kv.first->name_hint << "'";
domains_->UnifyExprExact(kv.first, kv.second);
VisitExpr(GetRef<Function>(function_node));
} else if (const auto* prim_func_node = kv.second.as<tir::PrimFuncNode>()) {
VLOG(2) << "collecting constraints from TIR PrimFunc '" << kv.first->name_hint << "'";
domains_->UnifyExprExact(
kv.first, DomainForPrimFunc(kv.first, GetRef<tir::PrimFunc>(prim_func_node)));
} else {
VLOG(2) << "skipping '" << kv.first->name_hint << "'";
}
}
return std::move(domains_);
}
private:
/*!
* \brief Return the domain representing \p prim_func which, before lowering, had
* the Relay \p type.
*/
DeviceDomainPtr DomainForPrimFunc(const GlobalVar& global_var, const tir::PrimFunc& prim_func) {
// CAUTION: The prim_func->checked_type() is currently w.r.t. the flattened and DPS form
// of the prim func, however here we wish to remain within the Relay view of all functions.
// Thus we'll use the global var who's checked_type is in Relay form.
auto func_domain = domains_->DomainFor(global_var); // higher-order
// TODO(mbs): We don't visit the body of the function -- there's currently nothing to be done.
const auto* func_type_node = global_var->checked_type().as<FuncTypeNode>();
ICHECK(func_type_node);
ICHECK_EQ(func_domain->function_arity(), func_type_node->arg_types.size());
Array<VirtualDevice> virtual_devices =
tir::GetPrimFuncArgAndResultConstraints(prim_func, GetRef<FuncType>(func_type_node));
// Build the implied domain (in terms of the function's Relay type) implied by any memory scope
// constrains in the function's buffers, for both arguments and results.
std::vector<DeviceDomainPtr> args_and_result_domains;
args_and_result_domains.reserve(virtual_devices.size());
for (size_t i = 0; i < func_type_node->arg_types.size(); ++i) {
const VirtualDevice& param_virtual_device = virtual_devices[i];
VLOG(2) << "param_virtual_device[" << i << "] = " << param_virtual_device;
args_and_result_domains.push_back(domains_->MakeFirstOrderDomain(param_virtual_device));
}
const VirtualDevice& ret_virtual_device = virtual_devices.back();
VLOG(2) << "ret_virtual_device = " << ret_virtual_device;
args_and_result_domains.push_back(domains_->MakeFirstOrderDomain(ret_virtual_device));
return domains_->MakeHigherOrderDomain(std::move(args_and_result_domains));
}
void VisitExpr_(const CallNode* call_node) final {
auto call = GetRef<Call>(call_node);
// We don't care if the call is in pre- or post-lowered form.
auto vanilla_call = GetAnyCall(call_node);
// Find the higher-order domain for the callee. See DomainForCallee for the special rules
// for primitives.
VisitExpr(vanilla_call->op);
auto func_domain = domains_->DomainForCallee(call); // higher-order
// Build the domain for the function implied by its arguments and call context.
ICHECK_EQ(func_domain->function_arity(), vanilla_call->args.size()) << PrettyPrint(call);
std::vector<DeviceDomainPtr> args_and_result_domains;
args_and_result_domains.reserve(vanilla_call->args.size() + 1);
for (const auto& arg : vanilla_call->args) {
args_and_result_domains.emplace_back(domains_->DomainFor(arg));
}
args_and_result_domains.emplace_back(domains_->DomainFor(call));
auto implied_domain =
domains_->MakeHigherOrderDomain(std::move(args_and_result_domains)); // higher-order
VLOG(2) << "initial call function domain:" << std::endl
<< domains_->ToString(func_domain) << std::endl
<< "and implied domain:" << std::endl
<< domains_->ToString(implied_domain) << std::endl
<< "for call:" << std::endl
<< PrettyPrint(call);
// The above must match.
if (domains_->UnifyOrNull(func_domain, implied_domain) == nullptr) { // higher-order
// TODO(mbs): Proper diagnostics.
LOG(FATAL)
<< "Function parameters and result VirtualDevices do not match those of call. Call:"
<< std::endl
<< PrettyPrint(call) << std::endl
<< "with function virtual devices:" << std::endl
<< domains_->ToString(func_domain) << std::endl
<< "and implied call virtual devices:" << std::endl
<< domains_->ToString(implied_domain);
}
VLOG(2) << "final call function domain:" << std::endl
<< domains_->ToString(func_domain) << std::endl
<< "for call:" << std::endl
<< PrettyPrint(call);
}
void VisitExpr_(const LetNode* let_node) final {
Expr expr = GetRef<Let>(let_node);
// Iteratively visit let nodes to avoid stack overflow.
while (expr->IsInstance<LetNode>()) {
Let let = Downcast<Let>(expr);
// Let var must be same device as value it is bound to.
domains_->UnifyExprExact(let->var, let->value); // may be higher-order
// Let body must be same device as overall let.
domains_->UnifyExprExact(let, let->body); // may be higher-order
VisitExpr(let->var);
VisitExpr(let->value);
expr = let->body;
}
// Visit the last body
VisitExpr(expr);
}
void VisitExpr_(const FunctionNode* function_node) final {
auto function = GetRef<Function>(function_node);
auto func_domain = domains_->DomainFor(function); // higher-order
ICHECK_EQ(func_domain->function_arity(), function_node->params.size());
VLOG(2) << "initial function domain:" << std::endl
<< domains_->ToString(func_domain) << std::endl
<< "and function body domain:" << std::endl
<< domains_->ToString(domains_->DomainFor(function_node->body)) << std::endl
<< "for function:" << std::endl
<< PrettyPrint(function);
// The function body domain must match the function result domain.
domains_->UnifyExprExact(function_node->body,
func_domain->function_result()); // may be higher-order
if (!function_node->virtual_device()->IsFullyUnconstrained()) {
// The function body domain must match any existing virtual device annotation.
domains_->UnifyExprExact(function_node->body,
domains_->ForVirtualDevice(function_node->body->checked_type(),
function_node->virtual_device()));
}
for (size_t i = 0; i < function_node->params.size(); ++i) {
const auto& param = function_node->params[i];
// The parameter domain must match the function argument domain.
domains_->UnifyExprExact(param,
func_domain->function_param(i)); // may be higher-order
if (!param->virtual_device()->IsFullyUnconstrained()) {
// The parameter domain must match any existing virtual device annotation.
domains_->UnifyExprExact(
param, domains_->ForVirtualDevice(param->checked_type(), param->virtual_device()));
}
VisitExpr(param);
}
// No need to step into the body of Primitive functions.
if (!function_node->HasNonzeroAttr(attr::kPrimitive)) {
VisitExpr(function_node->body);
}
VLOG(2) << "final function domain:" << std::endl
<< domains_->ToString(func_domain) << std::endl
<< "and function body domain:" << std::endl
<< domains_->ToString(domains_->DomainFor(function_node->body)) << std::endl
<< "for function:" << std::endl
<< PrettyPrint(function);
}
void VisitExpr_(const TupleNode* tuple_node) final {
Tuple tuple = GetRef<Tuple>(tuple_node);
for (size_t i = 0; i < tuple->fields.size(); i++) {
auto domain = domains_->DomainFor(tuple->fields[i]); // may be higher-order
domains_->UnifyExprCollapsed(tuple, domain); // collapse to first-order if needed
}
}
void VisitExpr_(const TupleGetItemNode* tuple_get_item_node) final {
TupleGetItem tuple_get_item = GetRef<TupleGetItem>(tuple_get_item_node);
auto domain = domains_->DomainFor(tuple_get_item); // may be higher-order
domains_->UnifyExprCollapsed(tuple_get_item_node->tuple,
domain); // collapse to first-order if needed
}
class DevicePatternAnalyzer : public PatternVisitor {
public:
DevicePatternAnalyzer(DeviceDomains* domains, const ExprNode* adt_node)
: domains_(domains), adt_node_(adt_node) {}
private:
void VisitPattern_(const PatternVarNode* pattern_var_node) final {
auto var_domain = domains_->DomainFor(pattern_var_node->var); // may be higher order
domains_->UnifyExprCollapsed(GetRef<Expr>(adt_node_),
var_domain); // collapse to first-order if needed
}
/*! \brief (Mutable borrow of) the domains for all expressions processed so far. */
DeviceDomains* domains_;
/*! \brief The expression for the ADT we are matching over. */
const ExprNode* adt_node_;
};
void VisitPattern(const Pattern& pattern) final {}
void VisitExpr_(const MatchNode* match_node) final {
// For match node, we unify the value and the rhs of each clause
Match match = GetRef<Match>(match_node);
auto match_domain = domains_->DomainFor(match); // may be higher-order
DevicePatternAnalyzer pattern_analyzer(domains_.get(), match->data.get());
domains_->UnifyExprCollapsed(match->data, match_domain); // collapse to first-order if needed
for (const auto& clause : match->clauses) {
pattern_analyzer.VisitPattern(clause->lhs);
domains_->UnifyExprExact(clause->rhs, match_domain);
VisitExpr(clause->rhs);
}
VisitExpr(match_node->data);
}
void VisitExpr_(const GlobalVarNode* global_var_node) final {
domains_->DomainFor(GetRef<GlobalVar>(global_var_node));
}
void VisitExpr_(const VarNode* var_node) final { domains_->DomainFor(GetRef<Var>(var_node)); }
void VisitExpr_(const ConstantNode* constant_node) final {
domains_->DomainFor(GetRef<Constant>(constant_node));
}
void VisitExpr_(const ConstructorNode* constructor_node) final {
// no-op, constructors are handled at their call-sites.
// TODO(mbs): Assumes eta-expansion
}
void VisitExpr_(const IfNode* if_node) final {
auto ife = GetRef<If>(if_node);
auto domain = domains_->DomainFor(ife); // may be higher-order
domains_->UnifyExprCollapsed(if_node->cond, domain); // collapse to first-order if needed
domains_->UnifyExprExact(if_node->true_branch, domain);
domains_->UnifyExprExact(if_node->false_branch, domain);
VisitExpr(if_node->cond);
VisitExpr(if_node->true_branch);
VisitExpr(if_node->false_branch);
}
void VisitExpr_(const OpNode* op) final {
// no-op, primitive operators are handled at their call-sites.
}
void VisitExpr_(const RefCreateNode* ref_create_node) final {
auto ref_create = GetRef<RefCreate>(ref_create_node);
auto domain = domains_->DomainFor(ref_create_node->value); // may be higher-order
domains_->UnifyExprCollapsed(ref_create, domain); // collapse to first-order if needed
VisitExpr(ref_create_node->value);
}
void VisitExpr_(const RefReadNode* ref_read_node) final {
auto ref_read = GetRef<RefRead>(ref_read_node);
auto domain = domains_->DomainFor(ref_read); // may be higher-order
domains_->UnifyExprCollapsed(ref_read_node->ref, domain); // collapse to first-order if needed
VisitExpr(ref_read_node->ref);
}
void VisitExpr_(const RefWriteNode* ref_write_node) final {
auto ref_write = GetRef<RefWrite>(ref_write_node);
auto domain = domains_->DomainFor(ref_write->value); // may be higher-order
domains_->UnifyExprCollapsed(ref_write->ref, domain); // collapse to first-order if needed
domains_->UnifyExprCollapsed(ref_write, domain); // collapse to first-order if needed
VisitExpr(ref_write_node->ref);
VisitExpr(ref_write_node->value);
}
/*! \brief The module we are analyzing. */
IRModule mod_;
/*! \brief The domains for all expressions processed so far. */
std::unique_ptr<DeviceDomains> domains_;
};
/* =============== Phase 2 =============== */
/*!
* \brief Calls to 'free' "on_device" annotations (ie where both constrain_body=false and
* constrain_result=false) indicate a device_copy is allowed if required, but no particular
* device is imposed on the body or the context. At this stage we can attempt to unify the
* body and device contexts. In this way we can avoid the defaulting rules in \p DeviceDefaulter
* from choosing default devices which are only going to induce a device copy.
*
* TODO(mbs): The order in which we encounter the "on_device" calls can influence the final global
* device assignment. However we visit global functions in hash map order.
*/
class FreeOnDeviceDefaulter : public ExprVisitor {
public:
FreeOnDeviceDefaulter(IRModule mod, std::unique_ptr<DeviceDomains> domains)
: mod_(std::move(mod)), domains_(std::move(domains)) {}
std::unique_ptr<DeviceDomains> Default() {
VLOG_CONTEXT << "FreeOnDeviceDefaulter";
VLOG(0) << "unifying free on_device annotations";
for (const auto& kv : mod_->functions) {
if (const auto* function_node = AsOptimizableFunctionNode(kv.second)) {
VLOG(2) << "unifying for '" << kv.first->name_hint << "'";
VisitExpr(GetRef<Function>(function_node));
} else {
VLOG(2) << "skipping '" << kv.first->name_hint << "'";
}
}
return std::move(domains_);
}
private:
void VisitExpr_(const CallNode* call_node) final {
auto call = GetRef<Call>(call_node);
OnDeviceProps props = GetOnDeviceProps(call_node);
ExprVisitor::VisitExpr_(call_node);
if (props.body.defined() && !props.constrain_body && !props.constrain_result) {
domains_->OptionalUnifyExprExact(call, props.body);
}
}
/*! \brief The module we are processing. */
IRModule mod_;
/*! \brief The domains for all expressions. */
std::unique_ptr<DeviceDomains> domains_;
};
/*!
* \brief Ensures every sub-expression in a module has a device type, using both the global
* default and some local heuristics to avoid unnecessary additional "device_copy" CallNodes.
*
* E.g. in:
* \code
* def @main(%x, %y, %z) {
* let %a = add(%x, %y);
* multiply(%a, on_device(%z, virtual_device=d))
* }
* \endcode
* we know the parameter \p %z must be on device \p d, but the devices for \p %x and \p %y,
* and the device for the function result, are still 'free'. The global 'default' device type
* is first used to 'fix' \p main's result type, which in turn 'fixes' \p %x and \p %y, which
* in turn 'fixes' the device on which the \p add and \p multiply are executed.
*
* TODO(mbs): I think this is deterministic? We do however visit the top-level defs in hashmap
* order.
*/
class DeviceDefaulter : public ExprVisitor {
public:
DeviceDefaulter(IRModule mod, std::unique_ptr<DeviceDomains> domains)
: mod_(std::move(mod)), domains_(std::move(domains)) {}
std::unique_ptr<DeviceDomains> Default() {
VLOG_CONTEXT << "DeviceDefaulter";
VLOG(0) << "defaulting to VirtualDevice "
<< domains_->config()->default_primitive_virtual_device;
for (const auto& kv : mod_->functions) {
if (const auto* function_node = AsOptimizableFunctionNode(kv.second)) {
VLOG(2) << "defaulting devices for '" << kv.first->name_hint << "'";
VisitExpr(GetRef<Function>(function_node));
} else {
VLOG(2) << "skipping '" << kv.first->name_hint << "'";
}
}
return std::move(domains_);
}
private:
void VisitExpr_(const FunctionNode* function_node) final {
if (function_node->HasNonzeroAttr(attr::kPrimitive)) {
return;
}
auto function = GetRef<Function>(function_node);
auto func_domain = domains_->DomainFor(function); // higher-order
ICHECK_EQ(func_domain->function_arity(), function_node->params.size());
if (!domains_->IsFullyConstrained(func_domain)) {
VLOG(2) << "before defaulting function:" << std::endl << domains_->ToString(func_domain);
domains_->SetResultDefaultThenParams(func_domain,
domains_->config()->default_primitive_virtual_device);
VLOG(2) << "after defaulting function:" << std::endl << domains_->ToString(func_domain);
}
VisitExpr(function_node->body);
}
void VisitExpr_(const CallNode* call_node) final {
auto call = GetRef<Call>(call_node);
// We don't care if the call is pre- or post-lowered.
auto vanilla_call = GetAnyCall(call_node);
auto func_domain = domains_->DomainForCallee(call); // higher-order
ICHECK_EQ(func_domain->function_arity(), vanilla_call->args.size());
if (!domains_->IsFullyConstrained(func_domain)) {
// For calls to Relay functions this step is identical to that for VisitExpr_(FunctionNode*)
// above. But for calls to primitives we may still need to force free domains to be
// defaulted.
VLOG(2) << "before defaulting callee:" << std::endl
<< PrettyPrint(call_node->op) << std::endl
<< "of domain:" << std::endl
<< domains_->ToString(func_domain);
domains_->SetResultDefaultThenParams(func_domain,
domains_->config()->default_primitive_virtual_device);
VLOG(2) << "after defaulting callee:" << std::endl
<< PrettyPrint(call_node->op) << std::endl
<< "of domain:" << std::endl
<< domains_->ToString(func_domain);
}
return ExprVisitor::VisitExpr_(call_node);
}
void VisitExpr_(const LetNode* let_node) final {
Expr expr = GetRef<Let>(let_node);
// Iteratively visit let nodes to avoid stack overflow.
while (expr->IsInstance<LetNode>()) {
Let let = Downcast<Let>(expr);
// If the let-var device is still free force it to match the overall let.
auto let_domain = domains_->DomainFor(let); // may be higher-order
VirtualDevice let_virtual_device = domains_->ResultVirtualDevice(let_domain);
ICHECK(!let_virtual_device->IsFullyUnconstrained());
auto let_var_domain = domains_->DomainFor(let->var); // may be higher-order
if (!domains_->IsFullyConstrained(let_var_domain)) {
VLOG(2) << "before defaulting let-var:" << std::endl << domains_->ToString(let_var_domain);
domains_->SetDefault(let_var_domain, let_virtual_device);
VLOG(2) << "after defaulting let-var:" << std::endl << domains_->ToString(let_var_domain);
}
VisitExpr(let->var);
VisitExpr(let->value);
expr = let->body;
}
VisitExpr(expr);
}
/*! \brief The module we are processing. */
IRModule mod_;
/*! \brief The domains for all expressions. */
std::unique_ptr<DeviceDomains> domains_;
};
/* =============== Phase 3 =============== */
/*!
* \brief Inserts missing "device_copy" CallNodes, and ensures the device type of every
* sub-expression in a module can be easily recovered by a later transformation using simple
* lexical scoping rules (e.g. for memory planning).
*
* - Discard any existing "on_device" CallNodes since their job is done. Similarly, discard
* any existing "device_copy" CallNodes which are no-ops.
*
* - The result virtual device for a function is stored in the function's virtual_device_ field
* and the virtual devices of the function's parameters are stored in the parameter's
* virtual_device_ field.
*
* - Additional "device_copy" CallNodes are inserted wherever there's a transition between
* storage device types. Since the DeviceAnalyzer phase succeeded this can only happen
* where the original program explicitly allowed a transition using an "on_device" CallNode.
* That is, we do not not try to 'fix' a program with inconsistent devices.
*
* - Additional "on_device" CallNodes are inserted so that a later transform can discover
* the device for an arbitrary sub-expression by looking only for the lexically enclosing
* "on_device" CallNode or "on_device" function attribute. In particular, since function
* arguments and let-bound expressions can be on a device different from the function
* or let body itself we will insert "on_device" CallNodes to spell out any differences. This
* applies even to the argument to a "device_copy" CallNode, which may look pedantic but
* keeps downstream processing simple. The "on_device" calls should be removed before code gen,
* which is easily done on-the-fly.
*
* - Update memory scopes in PrimFunc buffer maps.
*
* For example, we'll end up with programs that look like:
* \code
* def @main(%x, %y, param_virtual_devices=[...], result_virtual_device=...) {
* let %a = on_device(..., virtual_device=..., is_fixed=True)
* @f(%a, device_copy(on_device(..., virtual_device=..., is_fixed=True),
* src_virtual_device=..., dst_virtual_device=...))
* }
* \endcode
*/
class DeviceCapturer : public ExprMutator {
public:
DeviceCapturer(IRModule mod, std::unique_ptr<DeviceDomains> domains)
: mod_(std::move(mod)), domains_(std::move(domains)) {}
IRModule Capture() {
VLOG_CONTEXT << "CaptureDevices";
IRModule result(/*functions=*/{}, mod_->type_definitions, mod_->Imports(), mod_->source_map,
mod_->attrs);
for (const auto& kv : mod_->functions) {
if (const auto* function_node = AsOptimizableFunctionNode(kv.second)) {
VLOG(2) << "capturing devices for Relay Function '" << kv.first->name_hint << "'";
result->Add(kv.first, Downcast<Function>(Mutate(GetRef<Function>(function_node))));
} else if (const auto* prim_func_node = kv.second.as<tir::PrimFuncNode>()) {
VLOG(2) << "capturing devices for TIR PrimFunc '" << kv.first->name_hint << "'";
auto prim_func = GetRef<tir::PrimFunc>(prim_func_node);
tir::PrimFunc new_prim_func = UpdatePrimFunc(kv.first, prim_func);
VLOG(2) << "Rewritten prim func:" << std::endl
<< PrettyPrint(prim_func) << std::endl
<< "to:" << std::endl
<< PrettyPrint(new_prim_func);
result->Add(kv.first, std::move(new_prim_func));
} else {
VLOG(2) << "skipping '" << kv.first->name_hint << "'";
result->Add(kv.first, kv.second);
}
}
return result;
}
private:
/*!
* \brief Returns \p prim_func updated to capture any memory scope's implied by its device
* domain.
*/
tir::PrimFunc UpdatePrimFunc(const GlobalVar& global_var, const tir::PrimFunc& prim_func) {
// CAUTION: Same caution as for DeviceAnalyzer::DomainForPrimFunc.
auto func_domain = domains_->DomainFor(global_var);
ICHECK(func_domain->is_higher_order());
const auto* func_type_node = global_var->checked_type().as<FuncTypeNode>();
ICHECK(func_type_node);
ICHECK_EQ(func_domain->function_arity(), func_type_node->arg_types.size());
std::vector<VirtualDevice> arg_and_result_virtual_devices;
arg_and_result_virtual_devices.reserve(func_type_node->arg_types.size() + 1);
for (size_t i = 0; i < func_type_node->arg_types.size(); ++i) {
VirtualDevice param_virtual_device =
domains_->ResultVirtualDevice(func_domain->function_param(i));
VLOG(2) << "param_virtual_device[" << i << "] = " << param_virtual_device;
arg_and_result_virtual_devices.push_back(param_virtual_device);
}
VirtualDevice ret_virtual_device =
domains_->ResultVirtualDevice(func_domain->function_result());
VLOG(2) << "ret_virtual_device = " << ret_virtual_device;
arg_and_result_virtual_devices.push_back(ret_virtual_device);
return tir::ApplyPrimFuncArgAndResultConstraints(prim_func, GetRef<FuncType>(func_type_node),
arg_and_result_virtual_devices);
}
// Nothing interesting for VarNode, ConstantNode, GlobalVarNode, OpNode and ConstructorNode
Expr VisitExpr_(const TupleNode* tuple_node) final {
auto tuple = GetRef<Tuple>(tuple_node);
Array<Expr> fields;
fields.reserve(tuple_node->fields.size());
for (const auto& field : tuple_node->fields) {
fields.push_back(VisitChild(tuple, field));
}
return WithFields(tuple, fields);
}
Expr VisitExpr_(const FunctionNode* function_node) final {
if (function_node->HasNonzeroAttr(attr::kPrimitive)) {
return GetRef<Function>(function_node);
}
auto function = GetRef<Function>(function_node);
auto func_domain = domains_->DomainFor(function); // higher-order
VLOG(2) << "capturing function:" << std::endl
<< PrettyPrint(function) << std::endl
<< "with domain:" << std::endl
<< domains_->ToString(func_domain);
// Gather the parameter and result device types for the function attributes.
ICHECK_EQ(func_domain->function_arity(), function_node->params.size());
VirtualDevice result_virtual_device = domains_->ResultVirtualDevice(func_domain);
ICHECK(!result_virtual_device->IsFullyUnconstrained());
// Map the function parameters to a new variable annotated with a virtual device so
// we can substitute them later.
Map<Var, Expr> annotated_bind_map;
Array<Var> annotated_params;
annotated_params.reserve(function_node->params.size());
for (size_t i = 0; i < function_node->params.size(); ++i) {
VirtualDevice param_virtual_device =
domains_->ResultVirtualDevice(func_domain->function_param(i));
VLOG(4) << "Param: " << function_node->params[i];
Var annotated_var = WithFields(function_node->params[i], {}, {}, param_virtual_device);
VLOG(4) << "Annotated param: " << annotated_var;
VLOG(4) << "VirtualDevice: " << annotated_var->virtual_device();
ICHECK(!param_virtual_device->IsFullyUnconstrained());
annotated_bind_map.Set(function_node->params[i], annotated_var);
annotated_params.push_back(annotated_var);
}
// Eventually we probably want to bind before visiting, but for now this is causing an issue
// with the GetVirtualDevice utility, so leaving as is for now.
// Rewrite the body. Note that the body may have begun with an "on_device" so
// be prepared to insert a "device_copy".
Expr body = VisitChild(
/*lexical_virtual_device=*/result_virtual_device,
/*expected_virtual_device=*/result_virtual_device,
/*child_virtual_device=*/GetVirtualDevice(function_node->body), function_node->body);
VLOG(4) << "Visited body: " << body;
Function func = WithFields(GetRef<Function>(function_node), function_node->params, body);
VLOG(4) << "New function: " << func;
func = SubstituteBoundVars(func, annotated_bind_map);
VLOG(4) << "Func with bound params: " << func;
func->virtual_device_ = result_virtual_device;
VLOG(4) << "Func with bound params & result vid set: " << func;
return std::move(func);
}
Expr VisitExpr_(const CallNode* call_node) final {
auto call = GetRef<Call>(call_node);
// We don't care if the call is pre- or post-lowered
// (However we'll preserve the form in the result below.)
auto vanilla_call = GetAnyCall(call_node);
VirtualDevice call_virtual_device = GetVirtualDevice(call);
auto on_device_props = GetOnDeviceProps(call_node);
if (on_device_props.body.defined()) {
// We're done with the original "on_device" calls and can pinch them out.
// Note that this step has already been simulated by GetDeviceType.
return VisitExpr(on_device_props.body);
}
DeviceCopyProps device_copy_props = GetDeviceCopyProps(call_node);
if (device_copy_props.body.defined()) {
VirtualDevice src_virtual_device =
domains_->config()->CanonicalVirtualDevice(device_copy_props.src_virtual_device);
VirtualDevice dst_virtual_device =
domains_->config()->CanonicalVirtualDevice(device_copy_props.dst_virtual_device);
ICHECK_EQ(call_virtual_device, dst_virtual_device);
if (src_virtual_device == dst_virtual_device) {
// We can pinch out existing "device_copy" CallNodes if their source and destinations
// match.
return VisitExpr(device_copy_props.body);
} else {
return VisitChild(/*lexical_virtual_device=*/dst_virtual_device,
/*expected_virtual_device=*/dst_virtual_device,
/*child_virtual_device=*/src_virtual_device, device_copy_props.body);
}
}
// Generic call.
auto func_domain = domains_->DomainForCallee(call); // higher-order
VLOG(2) << "considering call:" << std::endl
<< PrettyPrint(call) << std::endl
<< "in virtual device " << call_virtual_device
<< " with function virtual devices:" << std::endl
<< domains_->ToString(func_domain);
VirtualDevice result_virtual_device = domains_->ResultVirtualDevice(func_domain);
ICHECK(!result_virtual_device->IsFullyUnconstrained());
// The callee is on the current device.
Expr op = VisitChild(
/*lexical_virtual_device=*/call_virtual_device,
/*expected_virtual_device=*/call_virtual_device,
/*child_virtual_device=*/result_virtual_device, vanilla_call->op);
// Each argument can be on the device for the corresponding function parameter. However if
// any of those differ from the overall call device then wrap them in an "on_device" to
// help downstream transforms track devices lexically.
Array<Expr> args;
args.reserve(vanilla_call->args.size());
ICHECK_EQ(func_domain->function_arity(), vanilla_call->args.size());
for (size_t i = 0; i < vanilla_call->args.size(); ++i) {
VirtualDevice param_virtual_device =
domains_->ResultVirtualDevice(func_domain->function_param(i));
ICHECK(!param_virtual_device->IsFullyUnconstrained())
<< "for parameter " << i << " for call:" << std::endl
<< PrettyPrint(call);
args.push_back(VisitChild(/*lexical_virtual_device=*/call_virtual_device,
/*expected_virtual_device=*/param_virtual_device,
/*child_virtual_device=*/GetVirtualDevice(vanilla_call->args[i]),
vanilla_call->args[i]));
}
if (call_node->op == CallLoweredOp()) {
Call new_call =
CallLowered(Downcast<GlobalVar>(op), args, /*call_lowered_attrs=*/{}, /*span=*/{});
return WithFields(call, new_call->op, new_call->args);
} else {
return WithFields(call, op, args);
}
}
Expr VisitExpr_(const LetNode* let_node) final {
Expr expr = GetRef<Expr>(let_node);
// Iterate through chained lets, provided they all agree on their device type.
VirtualDevice let_virtual_device = GetVirtualDevice(expr);
std::vector<std::tuple<Var, Expr, Span>> bindings;
while (const auto* inner_let_node = expr.as<LetNode>()) {
Expr inner_let = GetRef<Let>(inner_let_node);
if (GetVirtualDevice(inner_let) != let_virtual_device) {
// We have a device transition which needs to be handled.
break;
}
// The let-bound value can be on a different device than the overall let.
// By using the fully-unconstrained virtual device for the 'lexical' scope we'll force the
// let-bound value to *always* be wrapped by an "on_device" (see introductory comment for
// motivation.)
Expr value = VisitChild(/*lexical_virtual_device=*/VirtualDevice::FullyUnconstrained(),
/*expected_virtual_device=*/GetVirtualDevice(inner_let_node->var),
/*child_virtual_device=*/GetVirtualDevice(inner_let_node->value),
inner_let_node->value);
bindings.emplace_back(inner_let_node->var, value, inner_let_node->span);
expr = inner_let_node->body;
}
Expr body = VisitChild(/*lexical_virtual_device=*/let_virtual_device,
/*expected_virtual_device=*/let_virtual_device,
/*child_virtual_device=*/GetVirtualDevice(expr), expr);
for (auto itr = bindings.rbegin(); itr != bindings.rend(); ++itr) {
body = Let(/*var=*/std::get<0>(*itr), /*value=*/std::get<1>(*itr), body,
/*span=*/std::get<2>(*itr));
}
return body;
}
Expr VisitExpr_(const IfNode* if_node) final {
auto ife = GetRef<If>(if_node);
Expr cond = VisitChild(ife, if_node->cond);
Expr true_branch = VisitChild(ife, if_node->true_branch);
Expr false_branch = VisitChild(ife, if_node->false_branch);
return WithFields(ife, cond, true_branch, false_branch);
}
Expr VisitExpr_(const TupleGetItemNode* tuple_get_item_node) final {
auto tuple_get_item = GetRef<TupleGetItem>(tuple_get_item_node);
Expr tuple = VisitChild(tuple_get_item, tuple_get_item_node->tuple);
return WithFields(tuple_get_item, tuple);
}
Expr VisitExpr_(const RefCreateNode* ref_create_node) final {
auto ref_create = GetRef<RefCreate>(ref_create_node);
Expr value = VisitChild(ref_create, ref_create_node->value);
return WithFields(ref_create, value);
}
Expr VisitExpr_(const RefReadNode* ref_read_node) final {
auto ref_read = GetRef<RefRead>(ref_read_node);
Expr ref = VisitChild(ref_read, ref_read_node->ref);
return WithFields(ref_read, ref);
}
Expr VisitExpr_(const RefWriteNode* ref_write_node) final {
auto ref_write = GetRef<RefWrite>(ref_write_node);
Expr ref = VisitChild(ref_write, ref_write_node->ref);
Expr value = VisitChild(ref_write, ref_write_node->value);
return WithFields(ref_write, ref, value);
}
Expr VisitExpr_(const MatchNode* match_node) final {
auto match = GetRef<Match>(match_node);
Expr data = VisitChild(match, match_node->data);
Array<Clause> clauses;
clauses.reserve(match_node->clauses.size());
for (const auto& clause : match_node->clauses) {
Pattern lhs = VisitPattern(clause->lhs); // actually a no-op, so we're not checking vars
Expr rhs = VisitChild(match, clause->rhs);
clauses.push_back(Clause(lhs, rhs));
}
return WithFields(match, data, clauses);
}
VirtualDevice GetVirtualDevice(const Expr& expr) {
// Look through any "on_device" CallNodes, to mimic how we will be pinching them out.
OnDeviceProps props = GetOnDeviceProps(expr);
Expr true_expr = props.body.defined() ? props.body : expr;
ICHECK(domains_->contains(true_expr));
// If expr is higher order we'll return only the result domain's device.
VirtualDevice virtual_device = domains_->ResultVirtualDevice(domains_->DomainFor(true_expr));
ICHECK(!virtual_device->IsFullyUnconstrained())
<< "no VirtualDevice was determined for expression:" << std::endl
<< PrettyPrint(true_expr);
return std::move(virtual_device);
}
/*!
* \brief Reconcile the \p child_virtual_device for \p child with both the \p
* expected_virtual_device (as required by the expression context the \p child is in) and the \p
* lexical_virtual_device (as a downstream transform would infer based only on lexically enclosing
* "on_device" CallNodes and function attributes.) Generally \p lexical_virtual_device and \p
* expected_virtual_device are the same by definition, but may differ in arguments to functions
* and let-bound expressions.
*
* If \p child_virtual_device differs from \p expected_virtual_device, wrap it as:
* \code
* device_copy(on_device(child', virtual_device=child_virtual_device),
* src_dev_type=child_virtual_device, dst_dev_type=expected_virtual_device)
* \endcode
* (where child is rewritten to child'). Note the pedantic spelling out of "on_device" on the
* child.
*
* If \p expected_virtual_device differs from \p lexical_virtual_device, then (also) wrap
* the expression as:
* \code
* on_device(..., virtual_device=expected_virtual_device)
* \endcode
*
* TODO(mbs): There's no attempt at sharing here. If usage of child's node could be wrapped
* by a "device_copy", even though those copies will generally all be to the same destination
* device.
*/
Expr VisitChild(const VirtualDevice& lexical_virtual_device,
const VirtualDevice& expected_virtual_device,
const VirtualDevice& child_virtual_device, const Expr& child) {
ICHECK(!expected_virtual_device->IsFullyUnconstrained());
if (child->IsInstance<OpNode>() || child->IsInstance<ConstructorNode>()) {
// Primitive operators and contructors don't need to be rewritten and can have a
// different domain at each call site.
return child;
}
Expr result = VisitExpr(child);
if (child_virtual_device != expected_virtual_device) {
VLOG(2) << "creating " << DeviceCopyOp()->name << " from virtual device "
<< child_virtual_device << " to virtual device " << expected_virtual_device
<< " for:" << std::endl
<< PrettyPrint(result);
// Also wrap the child in an "on_device" so downstream transforms can track devices
// lexically.
result = MaybeOnDeviceFixed(result, child_virtual_device);
result = DeviceCopy(result, child_virtual_device, expected_virtual_device);
}
if (expected_virtual_device != lexical_virtual_device) {
VLOG(2) << "creating " << OnDeviceOp()->name << " for virtual device "
<< expected_virtual_device << " for:" << std::endl
<< PrettyPrint(result);
result = MaybeOnDeviceFixed(result, expected_virtual_device);
}
return result;
}
/*!
* Common case of visiting a direct \p child of \p parent where by default the \p child
* is expected to be on the same device as the \p parent.
*/
Expr VisitChild(const Expr& parent, const Expr& child) {
VirtualDevice expected_virtual_device = GetVirtualDevice(parent);
VirtualDevice child_virtual_device = GetVirtualDevice(child);
return VisitChild(expected_virtual_device, expected_virtual_device, child_virtual_device,
child);
}
/*! \brief Module we are rewriting, so we can lookup global variables. */
IRModule mod_;
/*! \brief Device domain for every expression from DeviceAnalyzer. */
std::unique_ptr<DeviceDomains> domains_;
};
/*! \brief Rewrite the "on_device" calls (and implicitly re-type-check). */
tvm::transform::Pass Rewrite() {
auto pass_func = [](Function f, IRModule m, transform::PassContext ctxt) {
auto attrs = m->attrs;
auto r = Downcast<Function>(RewriteOnDevices(std::move(m)).Mutate(f));
return attrs.defined() ? WithAttrs(r, {attrs->dict}) : r;
};
return tvm::relay::transform::CreateFunctionPass(pass_func, 0, "PlanDevicesRewrite", {});
}
/*! \brief Run the remaining phases. */
tvm::transform::Pass PlanDevicesCore(CompilationConfig config) {
return tvm::transform::CreateModulePass(
[config = std::move(config)](IRModule mod,
tvm::transform::PassContext pass_cnxt) -> IRModule {
// Collect the system of constraints for every sub-expression using existing "on_device"
// and "device_copy" calls.
std::unique_ptr<DeviceDomains> domains = DeviceAnalyzer(mod, config).Analyze();
VLOG(3) << "Domains after analysis:" << std::endl << domains->ToString();
// Choose sensible default devices for every sub-expression if otherwise unconstrained
// by existing "on_device" or "device_copy" calls.
domains = FreeOnDeviceDefaulter(mod, std::move(domains)).Default();
domains = DeviceDefaulter(mod, std::move(domains)).Default();
VLOG(3) << "Domains after defaulting: " << std::endl << domains->ToString();
// Insert "device_copy" and "on_device" CallNodes where needed to unambiguously capture
// the above map, and attach additional "param_virtual_devices" and "result_virtual_device"
// attributes to all function definitions.
return DeviceCapturer(mod, std::move(domains)).Capture();
},
/*opt_level=*/0, "PlanDevicesCore", {});
}
} // namespace
/* =============== Driver =============== */
// This function is declared in the public <tvm/relay/transform.h>.
tvm::transform::Pass PlanDevices(CompilationConfig config) {
std::vector<Pass> passes;
passes.emplace_back(Rewrite());
passes.emplace_back(PlanDevicesCore(std::move(config)));
return tvm::transform::Sequential(passes, "PlanDevices");
}
TVM_REGISTER_GLOBAL("relay._transform.PlanDevices").set_body_typed(PlanDevices);
} // namespace transform
} // namespace relay
} // namespace tvm