@@ -515,7 +515,8 @@ def forward(self, a, b, c):
515515 def test_single_tensor_delegate_output_copied_to_host (self ):
516516 """A lowered module that returns one tensor, called from another module,
517517 has no getitem after its call. Its device output still gets a _d2h_copy,
518- and the next call on the same device reads it without a round-trip."""
518+ and the next call on the same device reads it without a round-trip. With
519+ skip_h2d_for_method_inputs, y, read by both calls, is placed once."""
519520
520521 inputs = (torch .randn (2 , 2 ), torch .randn (2 , 2 ))
521522 lowered = _lower_add_to_device (inputs , "cuda:0" )
@@ -528,26 +529,35 @@ def __init__(self):
528529 def forward (self , x , y ):
529530 return self .lowered (self .lowered (x , y ), y )
530531
531- for skip_d2h in (False , True ):
532+ configs = (
533+ PropagateDeviceConfig (),
534+ PropagateDeviceConfig (skip_h2d_for_method_inputs = True ),
535+ PropagateDeviceConfig (skip_d2h_for_method_outputs = True ),
536+ )
537+ for device_config in configs :
538+ skip_h2d = device_config .skip_h2d_for_method_inputs
539+ skip_d2h = device_config .skip_d2h_for_method_outputs
532540 et = to_edge (export (Model (), inputs )).to_executorch (
533541 ExecutorchBackendConfig (
534542 emit_stacktrace = False ,
535- propagate_device_config = PropagateDeviceConfig (
536- skip_d2h_for_method_outputs = skip_d2h
537- ),
543+ propagate_device_config = device_config ,
538544 enable_non_cpu_memory_planning = True ,
539545 )
540546 )
541547 program = et ._emitter_output .program
542548 gm = et .exported_program ().graph_module
543- with self .subTest (skip_d2h = skip_d2h ):
549+ case = f"skip_h2d={ skip_h2d } , skip_d2h={ skip_d2h } "
550+ with self .subTest (case = case ):
544551 nodes = _collect_device_copy_nodes (gm )
545552 self .assertEqual (len (nodes .delegate_nodes ), 2 )
546- # Only method inputs are copied to the device: x and y, then y again.
547- self .assertEqual (len (nodes .h2d_nodes ), 3 )
553+ # Only method inputs are copied to the device: x and y, then y
554+ # again, unless the caller places them.
555+ self .assertEqual (len (nodes .h2d_nodes ), 0 if skip_h2d else 3 )
548556 self .assertTrue (
549557 all (n .args [0 ].op == "placeholder" for n in nodes .h2d_nodes )
550558 )
559+ cuda_ph , _ = self ._collect_placeholders_by_device (gm )
560+ self .assertEqual (len (cuda_ph ), 2 if skip_h2d else 0 )
551561 # Only the method output can be copied back to the host.
552562 self .assertEqual (len (nodes .d2h_nodes ), 0 if skip_d2h else 1 )
553563 (output ,) = gm .graph .output_node ().args [0 ]
@@ -560,18 +570,10 @@ def forward(self, x, y):
560570 ),
561571 )
562572 self ._assert_nodes_buffer_device (
563- nodes .delegate_nodes ,
564- program ,
565- DeviceType .CUDA ,
566- f"skip_d2h={ skip_d2h } " ,
567- "Delegate" ,
573+ nodes .delegate_nodes , program , DeviceType .CUDA , case , "Delegate"
568574 )
569575 self ._assert_nodes_buffer_device (
570- nodes .d2h_nodes ,
571- program ,
572- DeviceType .CPU ,
573- f"skip_d2h={ skip_d2h } " ,
574- "D2H copy" ,
576+ nodes .d2h_nodes , program , DeviceType .CPU , case , "D2H copy"
575577 )
576578
577579 def test_skip_d2h_keeps_output_also_read_on_device (self ):
@@ -1521,6 +1523,142 @@ def forward(self, a):
15211523 ):
15221524 edge .to_executorch (et_config )
15231525
1526+ def test_skip_h2d_for_input_shared_by_device_delegates (self ):
1527+ """With skip_h2d_for_method_inputs=True, a method input read by two
1528+ delegates on the same device is placed on that device once, with no
1529+ _h2d_copy."""
1530+
1531+ class Model (torch .nn .Module ):
1532+ def forward (self , a , b ):
1533+ c = torch .sin (torch .add (a , b ))
1534+ return torch .add (a , c )
1535+
1536+ model = Model ()
1537+ inputs = (torch .randn (2 , 2 ), torch .randn (2 , 2 ))
1538+ et_config = ExecutorchBackendConfig (
1539+ emit_stacktrace = False ,
1540+ propagate_device_config = PropagateDeviceConfig (
1541+ skip_h2d_for_method_inputs = True
1542+ ),
1543+ enable_non_cpu_memory_planning = True ,
1544+ )
1545+ for pipeline , program , gm in self ._get_executorch_program (
1546+ model , inputs , DeviceAwarePartitioner ("cuda:0" ), et_config
1547+ ):
1548+ with self .subTest (pipeline = pipeline ):
1549+ nodes = _collect_device_copy_nodes (gm )
1550+ self .assertEqual (len (nodes .delegate_nodes ), 2 )
1551+ # Only sin's output, computed on the host, is copied.
1552+ self .assertEqual (len (nodes .h2d_nodes ), 1 )
1553+ cuda_ph , _ = self ._collect_placeholders_by_device (gm )
1554+ self .assertEqual (sorted (n .name for n in cuda_ph ), ["a" , "b" ])
1555+ self ._assert_nodes_buffer_device (
1556+ cuda_ph , program , DeviceType .CUDA , pipeline , "Placeholder"
1557+ )
1558+
1559+ def test_skip_h2d_refuses_input_read_off_device (self ):
1560+ """A method input cannot move to a device when a host op, or a delegate
1561+ on another device, also reads it."""
1562+
1563+ class HostOp (torch .nn .Module ):
1564+ def forward (self , a , b ):
1565+ return torch .add (a , b ), torch .sin (a )
1566+
1567+ class TwoDelegates (torch .nn .Module ):
1568+ def forward (self , a , b , c ):
1569+ return torch .add (a , b ), torch .add (a , c )
1570+
1571+ et_config = ExecutorchBackendConfig (
1572+ emit_stacktrace = False ,
1573+ propagate_device_config = PropagateDeviceConfig (
1574+ skip_h2d_for_method_inputs = True
1575+ ),
1576+ enable_non_cpu_memory_planning = True ,
1577+ )
1578+ cases = {
1579+ "host op" : (
1580+ HostOp (),
1581+ 2 ,
1582+ DeviceAwarePartitioner ("cuda:0" ),
1583+ "aten_sin_default" ,
1584+ ),
1585+ "other device index" : (
1586+ TwoDelegates (),
1587+ 3 ,
1588+ PerAddDeviceDelegatePartitioner (["cuda:0" , "cuda:1" ]),
1589+ "executorch_call_delegate_1" ,
1590+ ),
1591+ "other device type" : (
1592+ TwoDelegates (),
1593+ 3 ,
1594+ PerAddDeviceDelegatePartitioner (["cuda:0" , "cpu" ]),
1595+ "executorch_call_delegate_1" ,
1596+ ),
1597+ }
1598+ for case , (model , num_inputs , partitioner , reader ) in cases .items ():
1599+ ep = export (model , tuple (torch .randn (2 , 2 ) for _ in range (num_inputs )))
1600+ # Each pipeline gets its own check, so the first refusal cannot hide
1601+ # the second pipeline.
1602+ for pipeline in ("to_edge+to_backend" , "to_edge_transform_and_lower" ):
1603+ if pipeline == "to_edge+to_backend" :
1604+ edge = to_edge (
1605+ deepcopy (ep ),
1606+ compile_config = EdgeCompileConfig (_check_ir_validity = False ),
1607+ ).to_backend (partitioner )
1608+ else :
1609+ edge = to_edge_transform_and_lower (
1610+ deepcopy (ep ), partitioner = [partitioner ]
1611+ )
1612+ with self .subTest (case = case , pipeline = pipeline ):
1613+ with self .assertRaisesRegex (
1614+ RuntimeError , f"placeholder 'a' on cuda:0, because '{ reader } '"
1615+ ):
1616+ edge .to_executorch (deepcopy (et_config ))
1617+
1618+ def test_skip_h2d_refuses_placeholder_written_on_host (self ):
1619+ """A mutated input or mutable buffer that only a device delegate reads is
1620+ still written back on the host, so the refusal says so."""
1621+
1622+ class MutableBuffer (torch .nn .Module ):
1623+ def __init__ (self ):
1624+ super ().__init__ ()
1625+ self .register_buffer ("state" , torch .zeros (2 , 2 ))
1626+
1627+ def forward (self , a ):
1628+ out = torch .add (a , self .state )
1629+ self .state .add_ (a )
1630+ return out
1631+
1632+ class MutatedInput (torch .nn .Module ):
1633+ def forward (self , a , x ):
1634+ out = torch .add (a , x )
1635+ x .add_ (a )
1636+ return out
1637+
1638+ et_config = ExecutorchBackendConfig (
1639+ emit_stacktrace = False ,
1640+ propagate_device_config = PropagateDeviceConfig (
1641+ skip_h2d_for_method_inputs = True
1642+ ),
1643+ enable_non_cpu_memory_planning = True ,
1644+ )
1645+ cases = {
1646+ "mutable buffer" : (MutableBuffer (), 1 , "b_state" ),
1647+ "mutated input" : (MutatedInput (), 2 , "x" ),
1648+ }
1649+ for case , (model , num_inputs , name ) in cases .items ():
1650+ inputs = tuple (torch .randn (2 , 2 ) for _ in range (num_inputs ))
1651+ edge = to_edge (export (model , inputs )).to_backend (
1652+ DeviceAwarePartitioner ("cuda:0" )
1653+ )
1654+ with self .subTest (case = case ):
1655+ with self .assertRaisesRegex (
1656+ RuntimeError ,
1657+ f"placeholder '{ name } ' on cuda:0, because 'copy_' writes it "
1658+ r"back on the host \(the method mutates it\)" ,
1659+ ):
1660+ edge .to_executorch (deepcopy (et_config ))
1661+
15241662 def test_tensorspec_repr_includes_device (self ):
15251663 spec = TensorSpec (dtype = torch .float32 , shape = torch .Size ([2 , 3 ]))
15261664 repr_str = repr (spec )
0 commit comments