import bisect from typing import List, Dict from collections import OrderedDict from abc import ABC, abstractmethod from torch.fx.node import Node from .region import Region from .util import * @dataclass class ExecutionPeriod: start_time: float = 0 end_time: float = 0 class TrainingSimulator(ABC): """ The Training Simulator is used to simulate the training process. It records computation, communication, and runtime memory during forward and backward passes. Args: region_list (List[Region]): represents the linearized DNN computing graph. comp_power (float): the NVIDIA GPU FP16 compuing power. link_to_bw (Dict[str, Dict[float, float]]): communication links and the corresponding bandwidth. """ def __init__(self, region_list: List[Region], comp_power: float, link_to_bw: Dict[str, Dict[float, float]]) -> None: self.region_list = region_list self.region_num = len(region_list) self.runtime_mem: int = 0 self.peak_mem: int = 0 self.total_mem_saving: int = 0 self.fwd_node_mem: Dict[Node, float] = {} self.bwd_node_mem: Dict[Node, float] = {} # Node dependencies in backward pass self.bwd_node_deps: Dict[Node, int] = {} self.comp_power: float = comp_power self.link_to_bandwidth: Dict[str, Dict[float, float]] = link_to_bw @abstractmethod def execute(self): raise NotImplementedError @abstractmethod def _eval_fwd_mem_per_region(self, region: Region): raise NotImplementedError @abstractmethod def _eval_bwd_mem_per_region(self, region: Region): raise NotImplementedError def _get_bandwidth(self, link: str, comm_volumn: float) -> float: """ Get the data transfer bandwidth. Args: link (str): the data transfer link. comm_volumn (float): the amount of data transferred. Returns: float: the data transfer bandwidth. """ assert len(self.link_to_bandwidth) if link not in self.link_to_bandwidth: raise TypeError(f"Unknown data transfer link {link}") # size_list = sorted(list(map(float, self.link_to_bandwidth[link].keys()))) size_list = sorted(self.link_to_bandwidth[link].keys()) d_idx = bisect.bisect_left(size_list, comm_volumn) return self.link_to_bandwidth[link][size_list[d_idx]] def _get_communication_overhead(self, link: str, comm_volumn: float) -> float: return comm_volumn / self._get_bandwidth(link, comm_volumn) def _get_computing_overhead(self, flop: float) -> float: return flop / self.comp_power class SynTrainingSimulator(TrainingSimulator): def __init__(self, region_list: List[Region], comp_power: float, link_to_bw: Dict[str, Dict[float, float]]) -> None: super().__init__(region_list, comp_power, link_to_bw) def execute(self): """ Simulate synchronous training process. """ for reg in self.region_list: self._eval_fwd_mem_per_region(reg) for reg in self.region_list.__reversed__(): self._eval_bwd_mem_per_region(reg) def _eval_fwd_mem_per_region(self, region: Region): """ Evaluate the runtime and peak memory when the forward execution reaches the current region. """ # upload parameters of the current region if requires_upload_p_in_fwd(self.region_list[region.shared_rid]): self.runtime_mem += region.param_size for node in region.nodes: self.runtime_mem += calculate_fwd_tmp(node) + \ calculate_fwd_out(node) self.fwd_node_mem[node] = self.runtime_mem self.peak_mem = max(self.runtime_mem, self.peak_mem) self.total_mem_saving += node.node_info.runtime_fwd_mem - self.runtime_mem if region.need_offload: self.runtime_mem -= region.param_size def _eval_bwd_mem_per_region(self, region: Region): """ Evaluate the runtime and peak memory when the backward execution reaches the current region. """ # upload parameters of the current region if region.need_offload: self.runtime_mem += region.param_size # add the gradient of the parameter if region.r_id < region.shared_rid: # gradient accumulation is required for shared parameters self.runtime_mem += 2.0 * region.param_size else: self.runtime_mem += region.param_size for node in region.nodes.__reversed__(): self.runtime_mem -= calculate_fwd_out(node) self.runtime_mem += node.meta['bwd_mem_tmp'] + \ node.meta['bwd_mem_out'] self.peak_mem = max(self.runtime_mem, self.peak_mem) # The memory savings of a node may be negative due to parameter prefetch. self.total_mem_saving += node.node_info.runtime_bwd_mem - self.runtime_mem self.bwd_node_mem[node] = self.runtime_mem self.runtime_mem -= (node.meta['bwd_mem_tmp'] + calculate_fwd_tmp(node)) # free bwd_mem_out self.bwd_node_deps[node] = len(node.all_input_nodes) for user_node in node.users: if user_node in self.bwd_node_deps: self.bwd_node_deps[user_node] -= 1 if self.bwd_node_deps[user_node] <= 0: self.runtime_mem -= user_node.meta['bwd_mem_out'] if self.runtime_mem < 0: raise ValueError(f"region id: {region.r_id}, node name: {node.name}, " f"runtime_mem: {self.runtime_mem / 1024 ** 2:.3f}MB ---" f"runtime memory computed less than 0, which is miscalculated!") # release parameter and offload gradient in region if region.r_id == region.shared_rid: self.runtime_mem -= 2.0 * region.param_size elif region.r_id < region.shared_rid: self.runtime_mem -= 3.0 * region.param_size elif self.region_list[region.shared_rid].need_offload: self.runtime_mem -= region.param_size class AsynTrainingSimulator(TrainingSimulator): def __init__(self, region_list: List[Region], comp_power: float, link_to_bw: Dict[str, Dict[float, float]]) -> None: super().__init__(region_list, comp_power, link_to_bw) self.iter_end_time: int = 0 # the last computation execution period self.last_comp: ExecutionPeriod = ExecutionPeriod( start_time=0, end_time=0) # the last parameter prefetch execution period self.last_h2d: ExecutionPeriod = ExecutionPeriod( start_time=0, end_time=0) # the last gradient offload execution period self.last_d2h: ExecutionPeriod = ExecutionPeriod( start_time=0, end_time=0) # the forward computation execution period of the region self.fwd_reg_to_comp: OrderedDict[int, ExecutionPeriod] = OrderedDict() # the forward parameter prefetch execution period of the region self.fwd_reg_to_pref: OrderedDict[int, ExecutionPeriod] = OrderedDict() # the backward computation execution period of the region self.bwd_reg_to_comp: OrderedDict[int, ExecutionPeriod] = OrderedDict() # the backward parameter prefetch execution period of the region self.bwd_reg_to_pref: OrderedDict[int, ExecutionPeriod] = OrderedDict() # the gradient offload execution period of the region # which is divided into those that are waiting and those that have been released self.bwd_reg_to_offl_waiting: OrderedDict[int, ExecutionPeriod] = OrderedDict() self.bwd_reg_to_offl_freed: OrderedDict[int, ExecutionPeriod] = OrderedDict() # the region buffer, which records regions that are offloaded but not released self.reg_buffer_to_free: List[int] = [] # node dependencies in backward pass self.bwd_node_deps: Dict[Node, int] = {} # the region execution flow, # where fwd_reg_flow[i,j] denotes whether the parameters of j-th region are in the GPU # when the execution reaches the i-th region. self.fwd_reg_flow = torch.zeros( (self.region_num, self.region_num)).bool() self.bwd_reg_flow = torch.zeros( (self.region_num, self.region_num)).bool() def execute(self): """ Simulate asynchronous training process. In forward pass, parameter prefetching is advanced by one region. In backward pass, parameter prefetching is executed at the specified location, and gradient offloading is urgent. """ for reg in self.region_list: if reg.param_size and reg.r_id < self.region_num - 1: for nr in self.region_list[reg.r_id + 1:]: if nr.param_size and requires_upload_p_in_fwd(self.region_list[nr.shared_rid]): reg.fwd_prefetch_region = nr break self._eval_fwd_cost_per_region(reg) self._eval_fwd_mem_per_region(reg) for reg in self.region_list.__reversed__(): self._eval_bwd_cost_per_region(reg) self._eval_bwd_mem_per_region(reg) # release remaining grads for reg_id, offl_exec in self.bwd_reg_to_offl_waiting.items(): self.bwd_reg_to_offl_freed[reg_id] = offl_exec self.runtime_mem -= self.region_list[reg_id].param_size self.bwd_reg_to_offl_waiting.clear() self.iter_end_time = max( self.last_comp.end_time, self.last_d2h.end_time) def _insert_h2d_exec(self, region: Region, is_fwd: bool = True): """ Insert parameter prefetch execution period of the current region to the end of the h2d stream """ pref_start_time = max(self.last_h2d.end_time, self.last_comp.end_time) pref_end_time = pref_start_time + \ 2.0 * self._get_communication_overhead('h2d', region.param_size) pref_ep = ExecutionPeriod( start_time=pref_start_time, end_time=pref_end_time) if is_fwd: self.fwd_reg_to_pref[region.r_id] = pref_ep else: self.bwd_reg_to_pref[region.r_id] = pref_ep self.last_h2d = pref_ep def _insert_comp_exec(self, region: Region, is_fwd: bool = True): """ Insert computation execution period of the current region to the end of the computing stream """ if is_fwd: reg_to_comp = self.fwd_reg_to_comp reg_to_pref = self.fwd_reg_to_pref flop_key = 'fwd_flop' else: reg_to_comp = self.bwd_reg_to_comp reg_to_pref = self.bwd_reg_to_pref flop_key = 'bwd_flop' comp_start_time = max(self.last_comp.end_time, reg_to_pref.get( region.r_id, ExecutionPeriod(0, 0)).end_time) comp_end_time = comp_start_time + \ sum([self._get_computing_overhead(node.meta.get(flop_key, 0)) for node in region.nodes]) comp_ep = ExecutionPeriod( start_time=comp_start_time, end_time=comp_end_time) reg_to_comp[region.r_id] = comp_ep self.last_comp = comp_ep def _insert_d2h_exec(self, region: Region): """ Insert gradient offload execution period of the current region to the end of the d2h stream """ offl_start_time = max(self.last_d2h.end_time, self.last_comp.end_time) offl_end_time = offl_start_time + \ self._get_communication_overhead('d2h', region.param_size) offl_ep = ExecutionPeriod( start_time=offl_start_time, end_time=offl_end_time) self.bwd_reg_to_offl_waiting[region.r_id] = offl_ep self.last_d2h = offl_ep def _eval_fwd_cost_per_region(self, region: Region): """ Evaluate computation and communication execution period of the region in forward pass. """ # upload parameters of the first region if region.r_id == 0: self._insert_h2d_exec(region) # prefetch parameters of the next region fwd_prefetch_region = region.fwd_prefetch_region if fwd_prefetch_region and requires_upload_p_in_fwd(self.region_list[fwd_prefetch_region.shared_rid]): self._insert_h2d_exec(fwd_prefetch_region) # execute computation self._insert_comp_exec(region) def _eval_fwd_mem_per_region(self, region: Region): """ Evaluate the runtime and peak memory when the forward execution reaches the current region. """ # upload parameters of the current region if region.r_id <= 0: self.runtime_mem += region.param_size self.fwd_reg_flow[region.r_id, region.r_id] = True else: self.fwd_reg_flow[region.r_id] = self.fwd_reg_flow[region.r_id - 1] self.fwd_reg_flow[region.r_id, self.reg_buffer_to_free] = False self.reg_buffer_to_free.clear() # prefetch parameters of the next region fwd_prefetch_region = region.fwd_prefetch_region if fwd_prefetch_region and requires_upload_p_in_fwd(self.region_list[fwd_prefetch_region.shared_rid]): self.runtime_mem += fwd_prefetch_region.param_size self.fwd_reg_flow[region.r_id, fwd_prefetch_region.r_id] = True for node in region.nodes: self.runtime_mem += calculate_fwd_tmp(node) + \ calculate_fwd_out(node) self.peak_mem = max(self.runtime_mem, self.peak_mem) self.total_mem_saving += node.node_info.runtime_fwd_mem - self.runtime_mem self.fwd_node_mem[node] = self.runtime_mem if region.need_offload: self.runtime_mem -= region.param_size assert len( self.reg_buffer_to_free) <= 1, f'{len(self.reg_buffer_to_free)}' self.reg_buffer_to_free.append(region.r_id) def _eval_bwd_cost_per_region(self, region: Region): """ Evaluate computation and communication execution period of the region in backward pass. """ # upload parameters of the current region if region.is_syn: assert region.need_offload self._insert_h2d_exec(region, is_fwd=False) # prefetch parameters of the region choiced, which is parallel to computation if region.bwd_prefetch_region is not None: self._insert_h2d_exec(region.bwd_prefetch_region, is_fwd=False) # execute computation self._insert_comp_exec(region, is_fwd=False) # offload gradient if requires_offload_g_in_bwd(region): self._insert_d2h_exec(region) assert len(self.reg_buffer_to_free) == 0 for reg_id, offl_exec in self.bwd_reg_to_offl_waiting.items(): if offl_exec.end_time >= self.last_comp.start_time: break self.reg_buffer_to_free.append(reg_id) self.bwd_reg_to_offl_freed[reg_id] = offl_exec for reg_id in self.reg_buffer_to_free: self.bwd_reg_to_offl_waiting.pop(reg_id) def _eval_bwd_mem_per_region(self, region: Region): """ Evaluate the runtime and peak memory when the backward execution reaches the current region. """ if region.r_id + 1 < self.region_num: self.bwd_reg_flow[region.r_id] = self.bwd_reg_flow[region.r_id + 1] else: self.bwd_reg_flow[region.r_id] = self.fwd_reg_flow[-1] self.bwd_reg_flow[region.r_id, self.reg_buffer_to_free] = False # free gradients in the buffer while len(self.reg_buffer_to_free): reg_id = self.reg_buffer_to_free.pop(0) self.runtime_mem -= self.region_list[reg_id].param_size # upload parameters of the current region if region.is_syn: self.runtime_mem += region.param_size self.bwd_reg_flow[region.r_id, region.r_id] = True # prefetch parameters of the region choiced bwd_prefetch_region = region.bwd_prefetch_region if bwd_prefetch_region: self.runtime_mem += bwd_prefetch_region.param_size self.bwd_reg_flow[region.r_id, bwd_prefetch_region.r_id] = True # add the gradient of the parameter if region.r_id < region.shared_rid: # gradient accumulation is required for shared parameters self.runtime_mem += 2.0 * region.param_size else: self.runtime_mem += region.param_size for node in region.nodes.__reversed__(): self.runtime_mem -= calculate_fwd_out(node) self.runtime_mem += node.meta['bwd_mem_tmp'] + \ node.meta['bwd_mem_out'] self.peak_mem = max(self.runtime_mem, self.peak_mem) # The memory savings of a node may be negative due to parameter prefetch. self.total_mem_saving += node.node_info.runtime_bwd_mem - self.runtime_mem self.bwd_node_mem[node] = self.runtime_mem self.runtime_mem -= (node.meta['bwd_mem_tmp'] + calculate_fwd_tmp(node)) # free bwd_mem_out self.bwd_node_deps[node] = len(node.all_input_nodes) for user_node in node.users: if user_node in self.bwd_node_deps: self.bwd_node_deps[user_node] -= 1 if self.bwd_node_deps[user_node] <= 0: self.runtime_mem -= user_node.meta['bwd_mem_out'] if self.runtime_mem < 0: raise ValueError(f"region id: {region.r_id}, node name: {node.name}, " f"runtime_mem: {self.runtime_mem / 1024 ** 2:.3f}MB ---" f"runtime memory computed less than 0, which is miscalculated!") # release parameters of the region if requires_release_p_in_bwd(self.region_list[region.shared_rid]): self.runtime_mem -= region.param_size