@@ -545,7 +545,8 @@ def forward(self, x, y):
545545 def test_single_tensor_delegate_output_copied_to_host (self ):
546546 """A lowered module that returns one tensor, called from another module,
547547 has no getitem after its call. Its device output still gets a _d2h_copy,
548- and the next call on the same device reads it without a round-trip."""
548+ and the next call on the same device reads it without a round-trip. With
549+ skip_h2d_for_method_inputs, y, read by both calls, is placed once."""
549550
550551 inputs = (torch .randn (2 , 2 ), torch .randn (2 , 2 ))
551552 lowered = _lower_add_to_device (inputs , "cuda:0" )
@@ -558,26 +559,35 @@ def __init__(self):
558559 def forward (self , x , y ):
559560 return self .lowered (self .lowered (x , y ), y )
560561
561- for skip_d2h in (False , True ):
562+ configs = (
563+ PropagateDeviceConfig (),
564+ PropagateDeviceConfig (skip_h2d_for_method_inputs = True ),
565+ PropagateDeviceConfig (skip_d2h_for_method_outputs = True ),
566+ )
567+ for device_config in configs :
568+ skip_h2d = device_config .skip_h2d_for_method_inputs
569+ skip_d2h = device_config .skip_d2h_for_method_outputs
562570 et = to_edge (export (Model (), inputs )).to_executorch (
563571 ExecutorchBackendConfig (
564572 emit_stacktrace = False ,
565- propagate_device_config = PropagateDeviceConfig (
566- skip_d2h_for_method_outputs = skip_d2h
567- ),
573+ propagate_device_config = device_config ,
568574 enable_non_cpu_memory_planning = True ,
569575 )
570576 )
571577 program = et ._emitter_output .program
572578 gm = et .exported_program ().graph_module
573- with self .subTest (skip_d2h = skip_d2h ):
579+ case = f"skip_h2d={ skip_h2d } , skip_d2h={ skip_d2h } "
580+ with self .subTest (case = case ):
574581 nodes = _collect_device_copy_nodes (gm )
575582 self .assertEqual (len (nodes .delegate_nodes ), 2 )
576- # Only method inputs are copied to the device: x and y, then y again.
577- self .assertEqual (len (nodes .h2d_nodes ), 3 )
583+ # Only method inputs are copied to the device: x and y, then y
584+ # again, unless the caller places them.
585+ self .assertEqual (len (nodes .h2d_nodes ), 0 if skip_h2d else 3 )
578586 self .assertTrue (
579587 all (n .args [0 ].op == "placeholder" for n in nodes .h2d_nodes )
580588 )
589+ cuda_ph , _ = self ._collect_placeholders_by_device (gm )
590+ self .assertEqual (len (cuda_ph ), 2 if skip_h2d else 0 )
581591 # Only the method output can be copied back to the host.
582592 self .assertEqual (len (nodes .d2h_nodes ), 0 if skip_d2h else 1 )
583593 (output ,) = gm .graph .output_node ().args [0 ]
@@ -590,18 +600,10 @@ def forward(self, x, y):
590600 ),
591601 )
592602 self ._assert_nodes_buffer_device (
593- nodes .delegate_nodes ,
594- program ,
595- DeviceType .CUDA ,
596- f"skip_d2h={ skip_d2h } " ,
597- "Delegate" ,
603+ nodes .delegate_nodes , program , DeviceType .CUDA , case , "Delegate"
598604 )
599605 self ._assert_nodes_buffer_device (
600- nodes .d2h_nodes ,
601- program ,
602- DeviceType .CPU ,
603- f"skip_d2h={ skip_d2h } " ,
604- "D2H copy" ,
606+ nodes .d2h_nodes , program , DeviceType .CPU , case , "D2H copy"
605607 )
606608
607609 def test_skip_d2h_keeps_output_also_read_on_device (self ):
@@ -1607,6 +1609,142 @@ def forward(self, a):
16071609 ):
16081610 edge .to_executorch (et_config )
16091611
1612+ def test_skip_h2d_for_input_shared_by_device_delegates (self ):
1613+ """With skip_h2d_for_method_inputs=True, a method input read by two
1614+ delegates on the same device is placed on that device once, with no
1615+ _h2d_copy."""
1616+
1617+ class Model (torch .nn .Module ):
1618+ def forward (self , a , b ):
1619+ c = torch .sin (torch .add (a , b ))
1620+ return torch .add (a , c )
1621+
1622+ model = Model ()
1623+ inputs = (torch .randn (2 , 2 ), torch .randn (2 , 2 ))
1624+ et_config = ExecutorchBackendConfig (
1625+ emit_stacktrace = False ,
1626+ propagate_device_config = PropagateDeviceConfig (
1627+ skip_h2d_for_method_inputs = True
1628+ ),
1629+ enable_non_cpu_memory_planning = True ,
1630+ )
1631+ for pipeline , program , gm in self ._get_executorch_program (
1632+ model , inputs , DeviceAwarePartitioner ("cuda:0" ), et_config
1633+ ):
1634+ with self .subTest (pipeline = pipeline ):
1635+ nodes = _collect_device_copy_nodes (gm )
1636+ self .assertEqual (len (nodes .delegate_nodes ), 2 )
1637+ # Only sin's output, computed on the host, is copied.
1638+ self .assertEqual (len (nodes .h2d_nodes ), 1 )
1639+ cuda_ph , _ = self ._collect_placeholders_by_device (gm )
1640+ self .assertEqual (sorted (n .name for n in cuda_ph ), ["a" , "b" ])
1641+ self ._assert_nodes_buffer_device (
1642+ cuda_ph , program , DeviceType .CUDA , pipeline , "Placeholder"
1643+ )
1644+
1645+ def test_skip_h2d_refuses_input_read_off_device (self ):
1646+ """A method input cannot move to a device when a host op, or a delegate
1647+ on another device, also reads it."""
1648+
1649+ class HostOp (torch .nn .Module ):
1650+ def forward (self , a , b ):
1651+ return torch .add (a , b ), torch .sin (a )
1652+
1653+ class TwoDelegates (torch .nn .Module ):
1654+ def forward (self , a , b , c ):
1655+ return torch .add (a , b ), torch .add (a , c )
1656+
1657+ et_config = ExecutorchBackendConfig (
1658+ emit_stacktrace = False ,
1659+ propagate_device_config = PropagateDeviceConfig (
1660+ skip_h2d_for_method_inputs = True
1661+ ),
1662+ enable_non_cpu_memory_planning = True ,
1663+ )
1664+ cases = {
1665+ "host op" : (
1666+ HostOp (),
1667+ 2 ,
1668+ DeviceAwarePartitioner ("cuda:0" ),
1669+ "aten_sin_default" ,
1670+ ),
1671+ "other device index" : (
1672+ TwoDelegates (),
1673+ 3 ,
1674+ PerAddDeviceDelegatePartitioner (["cuda:0" , "cuda:1" ]),
1675+ "executorch_call_delegate_1" ,
1676+ ),
1677+ "other device type" : (
1678+ TwoDelegates (),
1679+ 3 ,
1680+ PerAddDeviceDelegatePartitioner (["cuda:0" , "cpu" ]),
1681+ "executorch_call_delegate_1" ,
1682+ ),
1683+ }
1684+ for case , (model , num_inputs , partitioner , reader ) in cases .items ():
1685+ ep = export (model , tuple (torch .randn (2 , 2 ) for _ in range (num_inputs )))
1686+ # Each pipeline gets its own check, so the first refusal cannot hide
1687+ # the second pipeline.
1688+ for pipeline in ("to_edge+to_backend" , "to_edge_transform_and_lower" ):
1689+ if pipeline == "to_edge+to_backend" :
1690+ edge = to_edge (
1691+ deepcopy (ep ),
1692+ compile_config = EdgeCompileConfig (_check_ir_validity = False ),
1693+ ).to_backend (partitioner )
1694+ else :
1695+ edge = to_edge_transform_and_lower (
1696+ deepcopy (ep ), partitioner = [partitioner ]
1697+ )
1698+ with self .subTest (case = case , pipeline = pipeline ):
1699+ with self .assertRaisesRegex (
1700+ RuntimeError , f"placeholder 'a' on cuda:0, because '{ reader } '"
1701+ ):
1702+ edge .to_executorch (deepcopy (et_config ))
1703+
1704+ def test_skip_h2d_refuses_placeholder_written_on_host (self ):
1705+ """A mutated input or mutable buffer that only a device delegate reads is
1706+ still written back on the host, so the refusal says so."""
1707+
1708+ class MutableBuffer (torch .nn .Module ):
1709+ def __init__ (self ):
1710+ super ().__init__ ()
1711+ self .register_buffer ("state" , torch .zeros (2 , 2 ))
1712+
1713+ def forward (self , a ):
1714+ out = torch .add (a , self .state )
1715+ self .state .add_ (a )
1716+ return out
1717+
1718+ class MutatedInput (torch .nn .Module ):
1719+ def forward (self , a , x ):
1720+ out = torch .add (a , x )
1721+ x .add_ (a )
1722+ return out
1723+
1724+ et_config = ExecutorchBackendConfig (
1725+ emit_stacktrace = False ,
1726+ propagate_device_config = PropagateDeviceConfig (
1727+ skip_h2d_for_method_inputs = True
1728+ ),
1729+ enable_non_cpu_memory_planning = True ,
1730+ )
1731+ cases = {
1732+ "mutable buffer" : (MutableBuffer (), 1 , "b_state" ),
1733+ "mutated input" : (MutatedInput (), 2 , "x" ),
1734+ }
1735+ for case , (model , num_inputs , name ) in cases .items ():
1736+ inputs = tuple (torch .randn (2 , 2 ) for _ in range (num_inputs ))
1737+ edge = to_edge (export (model , inputs )).to_backend (
1738+ DeviceAwarePartitioner ("cuda:0" )
1739+ )
1740+ with self .subTest (case = case ):
1741+ with self .assertRaisesRegex (
1742+ RuntimeError ,
1743+ f"placeholder '{ name } ' on cuda:0, because 'copy_' writes it "
1744+ r"back on the host \(the method mutates it\)" ,
1745+ ):
1746+ edge .to_executorch (deepcopy (et_config ))
1747+
16101748 def test_tensorspec_repr_includes_device (self ):
16111749 spec = TensorSpec (dtype = torch .float32 , shape = torch .Size ([2 , 3 ]))
16121750 repr_str = repr (spec )
0 commit comments