From b5d609878fe7edae594aac3bd36809e74d27707d Mon Sep 17 00:00:00 2001 From: Saaketh Date: Thu, 17 Aug 2023 14:39:36 -0700 Subject: [PATCH 01/31] simulationgit status --- Makefile | 3 + scripts/simulation/last_used_ordered_set.py | 39 ++ scripts/simulation/simulation_funcs.py | 440 ++++++++++++++++++++ scripts/simulation/simulation_script.py | 48 +++ scripts/simulation/simulation_web.py | 336 +++++++++++++++ scripts/simulation/static/loading.gif | Bin 0 -> 76341 bytes 6 files changed, 866 insertions(+) create mode 100644 scripts/simulation/last_used_ordered_set.py create mode 100644 scripts/simulation/simulation_funcs.py create mode 100644 scripts/simulation/simulation_script.py create mode 100644 scripts/simulation/simulation_web.py create mode 100644 scripts/simulation/static/loading.gif diff --git a/Makefile b/Makefile index 6f0ad67bb..aa246f915 100644 --- a/Makefile +++ b/Makefile @@ -28,4 +28,7 @@ test: web: uvicorn scripts.partition.web:app --port 1337 --reload +simulation: + uvicorn scripts.simulation.simulation_web:app --port 2000 --reload + .PHONY: test lint style diff --git a/scripts/simulation/last_used_ordered_set.py b/scripts/simulation/last_used_ordered_set.py new file mode 100644 index 000000000..25bb8d4e4 --- /dev/null +++ b/scripts/simulation/last_used_ordered_set.py @@ -0,0 +1,39 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""An ordered set that can be used as an LRU cache.""" + +from collections import OrderedDict +from typing import Any + +# custom ordered dictionary with setitem method to move items to the end of the dictionary if they are accessed + + +class LastUsedOrderedSet(OrderedDict): + """An ordered set that can be used as an LRU cache. + + This is a subclass of OrderedDict, with some LRU-specific functions and all values as ``None``. + """ + + def setitem(self, key: Any, move_to_end: bool = True): + """Set/add an item. + + Args: + key (Any): add a key. + move_to_end (bool, optional): whether to move the item to the end, signifying most recent access. Defaults to ``True``. + """ + super().__setitem__(key, None) + self.move_to_end(key, last=move_to_end) + + def popLRU(self): + """Pop the least recently used item (located at the front). + """ + self.popitem(last=False)[0] + + def setuse(self, key: Any): + """Mark an item as used, moving it to the end. + + Args: + key (Any): key of element to move to the end, signifying most recent access. + """ + self.setitem(key) \ No newline at end of file diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py new file mode 100644 index 000000000..41413de1a --- /dev/null +++ b/scripts/simulation/simulation_funcs.py @@ -0,0 +1,440 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Functions for simulating streaming and displaying results.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from io import BytesIO +from typing import Optional, Tuple + +import numpy as np +from numpy.typing import NDArray +from simulation.last_used_ordered_set import LastUsedOrderedSet +from sortedcollections import OrderedSet + +from streaming.base.partition import get_partitions +from streaming.base.shuffle import get_shuffle + + +def simulate(shards: int, + samples_per_shard: int, + avg_shard_size: float, + device_batch_size: int, + avg_batch_time: float, + batches_per_epoch: int, + epochs: int, + physical_nodes: int, + devices: int, + node_network_bandwidth: float, + workers: int, + canonical_nodes: int, + predownload: int, + cache_limit: Optional[int] = None, + shuffle_algo: Optional[str] = None, + shuffle_block_size: int = 1 << 18, + seed: int = 42) -> Tuple[NDArray, NDArray]: + """Simulates step time and downloads using streaming for the specified input parameters. + + Key Notes and Assumptions: + * assume that batch time is solely made up of two things: batch processing time and batch shard download wait time + * loop through workers round-robin style for batches and for downloads + * assume each node has a separate network bandwidth + * the batch has to wait until all nodes have downloaded the shards containing batch samples. + * for shard eviction itself, use LRU shard eviction to take out the least recently used shard, per node. + * if a shard is unavailable, we use the normal behavior and wait for the regular downloading process to get it. + * if a shard is available in a node, we just use it. + * each node maintains an ordered set of shards that are present in the node + * least recently used shard is the one at the front. most recently used is at the end of the ordered set. + * when a shard is accessed during the batch it is moved to the end of the ordered set + * when a shard is merely downloaded but not accessed for training, it goes to the front of the ordered set + * if cache_limit is set, we check for going above cache limit at every download -- all downloads are assumed same size + + Args: + shards (int): number of shards + samples_per_shard (int): number of samples per shard + avg_shard_size (float): average shard size (bytes) + device_batch_size (int): device batch size (samples) + avg_batch_time (float): average batch processing time (seconds) + batches_per_epoch (int): number of batches per epoch + epochs (int): number of epochs + physical_nodes (int): number of physical nodes + devices (int): number of devices per node + node_network_bandwidth (float): network bandwidth per node (bytes/s) + workers (int): number of workers per device + canonical_nodes (int): number of canonical nodes + predownload (int): number of samples to predownload per worker (samples) + cache_limit (int, optional): cache limit per node (bytes). Defaults to ``None``. + shuffle_algo (str, optional): shuffling algorithm. Defaults to ``None``. + shuffle_block_size (int): shuffling block size (samples). Defaults to ``1 << 18``. + seed (int): shuffling seed. Defaults to ``42``. + + Returns: + step_times (NDArray): time taken by each step, calculated by simulation. + shard_downloads (NDArray): amount of downloaded bytes at each step, calculated by simulation. + """ + + # simulation preparation... + + # we assume that each shard is going to be seen only once. Not handling up/down-sampling + # or multiple streams for now. + shard_sizes = np.array([samples_per_shard] * shards) + + # get partition of sample ids + # structured as (physical nodes, ranks per node, workers per rank, batches per worker, batch size) + partitions = get_partitions(algo='orig', + num_samples=shards * samples_per_shard, + num_canonical_nodes=canonical_nodes, + num_physical_nodes=physical_nodes, + ranks_per_node=devices, + workers_per_rank=workers, + batch_size=device_batch_size, + drop_first=0) + + # simulate training! + + # loop over epochs, then batches... + + notification_batches = int(batches_per_epoch) / 20 + + # track the shards which are present and evicted at each physical node + node_shards = [] + node_evictions = [] + for _ in range(physical_nodes): + node_shards.append(LastUsedOrderedSet()) + node_evictions.append(set()) + + # node cache useages are initially nothin' + node_cache_usage = np.array([0] * physical_nodes) + + # construct mapping of sample index -> shard number + sample_to_shard = np.repeat(np.arange(shards), samples_per_shard) + + # track stats for each step + step_times = [] + shard_downloads = [] + + for epoch in range(epochs): + + if shuffle_algo is not None: + # get shuffle of sample ids + shuffle = get_shuffle(algo=shuffle_algo, + shard_sizes=shard_sizes, + num_canonical_nodes=canonical_nodes, + seed=seed, + epoch=epoch, + block_size=shuffle_block_size) + # index into the shuffle to get the new sample at each index + partitions = np.where(partitions != -1, shuffle[partitions], -1) + + # handle initial predownload + # reshape shuffled_partition to get samples, in order, per worker + samples_per_worker = partitions.reshape(physical_nodes, devices, workers, -1) + + worker_sample_index = 0 # track which sample we are on. is an index per worker. + worker_download_indices = np.array( + [0] * physical_nodes + ) # track which worker we are on for downloading, per node, round-robin style + node_partial_shards = np.array( + [0] * physical_nodes) # track partial shard downloads at each node + + # construct download shard OrderedSets for every worker + # list of lists of OrderedSets. outer list is per node, inner list is per worker-device + node_worker_downloads = [] + for physical_node in range(physical_nodes): + worker_downloads = [] + # want to round-robin over devices, first, then workers so we don't only download samples from one device at a time + for worker in range(workers): + for device in range(devices): + download_samples = samples_per_worker[physical_node, device, + worker, :predownload] + # take out padded samples + download_samples = np.delete(download_samples, + np.where(download_samples == -1)) + # get the shards these samples correspond to -- still want to maintain access order! + download_shards = OrderedSet(sample_to_shard[download_samples]) + worker_downloads.append(download_shards) + node_worker_downloads.append(worker_downloads) + + for batch_num in range(batches_per_epoch): + + if (batch_num + 1) % notification_batches == 0: + print('Epoch: ' + str(epoch + 1) + ' | Batch ' + str(batch_num + 1) + '/' + + str(batches_per_epoch)) + + # we round robin over workers per device. current worker is same across all nodes and devices + curr_worker = batch_num % workers + + # track how long each node takes to download the shards that the current batch needs. + node_batch_download_times = np.array([0] * physical_nodes) + + # track how many shards we downloaded in this batch total + num_downloads = 0 + + # get current samples and predownload samples for each node, for this batch + for physical_node in range(physical_nodes): + curr_batch_samples = samples_per_worker[physical_node, :, curr_worker, + worker_sample_index:worker_sample_index + + device_batch_size].flatten() + + #remove samples that are -1 (padded) + curr_batch_samples = np.delete(curr_batch_samples, + np.where(curr_batch_samples == -1)) + + # get the shards these samples correspond to + curr_batch_shards = set(sample_to_shard[curr_batch_samples]) + + # shards we need to download is the set difference of shards already in node and current batch shards + shards_needed = curr_batch_shards.difference(node_shards[physical_node].keys()) + + # shards already present in the node -- we need to move these to the end of the node shards (we are using them). + shards_present = curr_batch_shards.difference(shards_needed) + + # update all shards_present as accessed most recently in this node's shards + for shard in shards_present: + # moves this shard to the end of the node shards + node_shards[physical_node].setuse(shard) + + # get the set of worker downloads for the current node + worker_downloads = node_worker_downloads[physical_node] + + # push the download range for the current worker forward by device_batch_size + for device in range(devices): + # only for current workers in batch, add any potential new shards to (pre)download. + # only the current workers have their (pre)download range moved at the current step. + new_download_samples = samples_per_worker[physical_node, device, curr_worker, + worker_sample_index + + predownload:worker_sample_index + + device_batch_size + predownload] + + #remove samples that are -1 (padded) + new_download_samples = np.delete(new_download_samples, + np.where(new_download_samples == -1)) + + # get the shards these samples correspond to, maintaining access order + new_download_shards = OrderedSet(sample_to_shard[new_download_samples]) + + # get set of curr_worker downloads per device + worker_download = worker_downloads[curr_worker * devices + device] + + # add these new shards to the predownload ONLY if they are not already in the node + # won't be any duplicates in the OrderedSet of worker downloads anyways. + for shard in new_download_shards: + if shard not in node_shards[physical_node]: + worker_download.add(shard) + + # get the current worker we are starting downloads from + curr_worker_download_index = worker_download_indices[physical_node] + + # num_batch_shards is different from len(shards_needed) because there is no guarantee that the shards we need + # are immediately downloaded first. Other shards from other workers may get downloaded before we download + # the shards needed for the current batch. + num_batch_shards = 0 + + # if we need shards for the current batch, we loop through worker downloads until there are no more shards needed + while len(shards_needed) > 0: + # traverse worker_downloads until we have a worker that has samples to predownload + empty_download_counter = 0 + while len(worker_downloads[curr_worker_download_index] + ) == 0 and empty_download_counter < devices * workers: + empty_download_counter += 1 + curr_worker_download_index = (curr_worker_download_index + 1) % (devices * + workers) + + # break out of predownload loop if no workers in the node have any predownloads. + if empty_download_counter >= devices * workers: + break + + # get the worker that has samples to predownload + worker_download = worker_downloads[curr_worker_download_index] + + # first entry in predownload is the next shard the worker wants + download_shard = worker_download[0] + + if download_shard not in node_shards[physical_node]: + # handle possible eviction + if cache_limit and node_cache_usage[ + physical_node] + avg_shard_size > cache_limit: + # evict the LRU shard + node_shards[physical_node].popLRU() + num_batch_shards += 1 + num_downloads += 1 + node_cache_usage[physical_node] += avg_shard_size + # add this shard to node_shards for the node that the worker is on + # second param as False means we don't move the shard to the end of the OrderedDict (we haven't actually used the shard yet) + # but if the shard is in shards_needed, we did actually use the shard and so we move it to the end of the node shards. + node_shards[physical_node].setitem(download_shard, True) + # if shard must have been in shards_needed, remove it + shards_needed.discard(download_shard) + # if shard used to be in node eviction list, remove it -- it's now present + node_evictions[physical_node].discard(download_shard) + + # discard from worker_download + worker_download.discard(download_shard) + + # increment download index + curr_worker_download_index = (curr_worker_download_index + 1) % (devices * + workers) + + # calculate how much time we spent downloading shards for this node for the current batch only + batch_download_time = (num_batch_shards * avg_shard_size) / node_network_bandwidth + node_batch_download_times[physical_node] = batch_download_time + + # update worker download index for this node + worker_download_indices[physical_node] = curr_worker_download_index + + # The batch will only start once all nodes have all the samples for the + # batch ready. So the true start time of the batch is determined by the longest batch_download_time + # over all nodes. And that means the download_time_left for nodes that finish earlier will be longer + # and only the slowest node will have a download_time_left of avg_batch_time. + slowest_download_time = np.max(node_batch_download_times) + for physical_node in range(physical_nodes): + + # we will always have the avg_batch_time to do more downloads, plus whatever amount of time this node finished early + download_time_left = avg_batch_time + (slowest_download_time - + node_batch_download_times[physical_node]) + + # get number of bytes/shards/remainder we can download in predownload_time + download_bytes_left = node_network_bandwidth * download_time_left + # number of shards we can download right now -- + # add in the fractional part of shard that may have been downloading from previous step + download_shards_left = ( + (download_bytes_left) / avg_shard_size) + node_partial_shards[physical_node] + + # get the current worker we are starting downloads from + curr_worker_download_index = worker_download_indices[physical_node] + + # get the set of worker downloads for the current node + worker_downloads = node_worker_downloads[physical_node] + + # while we can still download a whole shard, we keep predownloading shards in the allotted time. + while download_shards_left > 1: + # traverse worker_downloads until we have a worker that has samples to predownload + empty_download_counter = 0 + while len(worker_downloads[curr_worker_download_index] + ) == 0 and empty_download_counter < devices * workers: + empty_download_counter += 1 + curr_worker_download_index = (curr_worker_download_index + 1) % (devices * + workers) + + # break out of predownload loop if no workers in the node have any predownloads. + if empty_download_counter >= devices * workers: + break + + # get the worker that has samples to predownload + worker_download = worker_downloads[curr_worker_download_index] + + # first entry in predownload is the next shard the worker wants + download_shard = worker_download[0] + + if download_shard not in node_shards[physical_node]: + # handle possible eviction + if cache_limit and node_cache_usage[ + physical_node] + avg_shard_size > cache_limit: + # evict the LRU shard + node_shards[physical_node].popLRU() + num_downloads += 1 + node_cache_usage[physical_node] += avg_shard_size + # add this shard to node_shards for the node that the worker is on + # second param is False because this shard wasn't actually needed for the current batch. doesn't count as an actual access. + node_shards[physical_node].setitem(download_shard, False) + # decrement download_shards_left because we actually downloaded something + download_shards_left -= 1 + + # discard from worker_download + worker_download.discard(download_shard) + + # increment download index + curr_worker_download_index = (curr_worker_download_index + 1) % (devices * + workers) + + # insert download_shards_left into node_partial_shards + node_partial_shards[physical_node] = download_shards_left + + # update worker download index for this node + worker_download_indices[physical_node] = curr_worker_download_index + + step_times.append(slowest_download_time + avg_batch_time) + shard_downloads.append(num_downloads) + + # if we are at last worker, then the sample_index per worker should shift ahead by device_batch_size + if curr_worker == workers - 1: + worker_sample_index += device_batch_size + + step_times = np.array(step_times) + shard_downloads = avg_shard_size * np.array(shard_downloads) + + return step_times, shard_downloads + + +def plot_simulation(step_times: NDArray, + shard_downloads: NDArray, + web: bool = True, + window: int = 10) -> Optional[bytes]: + """Plots simulation results for web UI or local script. + + Args: + step_times (NDArray): time per step, as calculated by simulation + shard_downloads (NDArray): download size (bytes) per step, as calculated by simulation + web (bool, optional): True if displaying on web UI, False if displaying through local script. Defaults to `True``. + window (int, optional): window size to calculate batch throughput over. Defaults to ``10``. + + Returns: + Optional[bytes]: bytes of plot image if ``web`` is ``True``, else plot is displayed, and returns ``None``. + """ + + import matplotlib + if web: + matplotlib.use('agg') + import matplotlib.pyplot as plt + + immediate_batch_throughput = 1 / step_times + + shard_downloads_cumulative = np.cumsum(shard_downloads) + + step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + batch_throughput_rolling_avg = np.concatenate( + (np.array([0] * 9), batch_throughput_rolling_avg)) + + # matplotlib plot with 2 vertically stacked subplots + fig, (ax1, ax2) = plt.subplots(2, 1) + + plt.suptitle('Simulation Results', fontsize=16) + + ax1.plot(np.arange(immediate_batch_throughput.shape[0]), + immediate_batch_throughput, + color='lightblue', + label='per step throughput') + ax1.plot(np.arange(batch_throughput_rolling_avg.shape[0]), + batch_throughput_rolling_avg, + color='darkblue', + label='rolling throughput (10 step avg)') + ax1.legend() + ax1.set_ylim([0, max(immediate_batch_throughput) * 1.1]) + ax1.set_ylabel('batches/s') + ax1.set_title('batch throughput (batches/s)') + + ax2.plot(np.arange(shard_downloads_cumulative.shape[0]), + shard_downloads_cumulative, + color='blue', + label='total') + ax2.set_ylim([0, max(shard_downloads_cumulative) * 1.1]) + ax2.set_xlabel('step') + ax2.set_ylabel('cumulative download (bytes)') + ax2.set_title('network traffic (bytes)') + + fig.set_figheight(8) + fig.set_figwidth(6) + + if web: + buf = BytesIO() + fig.savefig(buf, format='png', dpi=fig.dpi) + buf.seek(0) + return buf.read() + else: + plt.show() + return None diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py new file mode 100644 index 000000000..45ff08345 --- /dev/null +++ b/scripts/simulation/simulation_script.py @@ -0,0 +1,48 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Script for simulating streaming and displaying results.""" + +import os.path +import sys +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from simulation_funcs import simulate, plot_simulation + +# Input Parameters + +# dataset +shards = 20000 # number of shards +samples_per_shard = 4000 # number of samples per shard +avg_shard_size = 1.6e7 # average shard size (bytes) + +# training +device_batch_size = 16 # device batch size (samples) +avg_batch_time = 0.27 # average batch processing time (seconds) +batches_per_epoch = 200 +epochs = 1 + +# streaming +workers = 8 # number of workers per device +canonical_nodes = 2 # number of canonical nodes +predownload = 3800 # number of samples to predownload per worker (samples) +cache_limit = None # cache limit per node (bytes) +shuffle_algo = 'py1b' # shuffling algorithm +shuffle_block_size = 16000000 # shuffling block size (samples) +seed = 17 # random seed + +# hardware and network +physical_nodes = 2 # number of physical nodes +devices = 8 # number of devices per node +node_network_bandwidth = 5e8 # network bandwidth per node (bytes/s) + +# ---------------------------------------------- # + +# simulate step times and shard downloads given the inputs +step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, device_batch_size, + avg_batch_time, batches_per_epoch, epochs, physical_nodes, devices, + node_network_bandwidth, workers, canonical_nodes, predownload, + cache_limit, shuffle_algo, shuffle_block_size, seed) + +# plot results +plot_simulation(step_times, shard_downloads, web=False) \ No newline at end of file diff --git a/scripts/simulation/simulation_web.py b/scripts/simulation/simulation_web.py new file mode 100644 index 000000000..90d90e099 --- /dev/null +++ b/scripts/simulation/simulation_web.py @@ -0,0 +1,336 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Web app to simulate streaming with different input params. + +Install: + + pip3 install fastapi pydantic uvicorn + +Run: + + uvicorn scripts.simulation:simulation_web:app --port 2000 --reload +""" + +import os.path +import sys +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import Optional +import base64 +from simulation.simulation_funcs import simulate, plot_simulation + +INDEX = ''' + + + + Streaming Simulator + + + + + + + + +
+
+ + + + + + + + + + + + + +
# shards
# samples per shard
average shard size (bytes)
+
+
+ + + + + + + + + + + + + + + + + +
# epochs
batches per epoch
device batch size
average batch time (seconds)
+
+
+ + + + + + + + + + + + + +
# physical nodes
# devices per node
internet bandwidth per node (bytes/sec)
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
# canonical nodes
# workers per device
predownload per worker
cache limit per node
shuffle algorithm
shuffle block size
shuffle seed
+
+
+
+
Simulate Streaming
+
+
+
+
+ + + +''' + +from pathlib import Path + +current_file = Path(__file__) +current_file_dir = current_file.parent +project_root = current_file_dir.parent +project_root_absolute = project_root.resolve() +static_root_absolute = project_root_absolute / "simulation/static" + +app = FastAPI() + +# mount static file directory for the nice loading gif :) +app.mount("/static", StaticFiles(directory=static_root_absolute), name="static") + +@app.get('/') +def get_root() -> HTMLResponse: + """Get the index HTML file.""" + return HTMLResponse(INDEX) + +class GetSimulationRequest(BaseModel): + """simulation input parameters.""" + shards: int + samples_per_shard: int + avg_shard_size: float + device_batch_size: int + avg_batch_time: float + batches_per_epoch: int + epochs: int + physical_nodes: int + devices: int + node_network_bandwidth: float + workers: int + canonical_nodes: int + predownload: int + cache_limit: Optional[int] = None + shuffle_algo: Optional[str] = None + shuffle_block_size: int = 1 << 18 + seed: int = 42 + +@app.post('/api/simulate') +def post_api_simulate(req: GetSimulationRequest) -> dict: + """Serve a POST request to simulate a run. + + Args: + req (GetSimulationRequest): The simulation input params. + + Returns: + dict: JSON object containing the base64 image string for the simulation plots. + """ + step_times, shard_downloads = simulate(req.shards, req.samples_per_shard, req.avg_shard_size, req.device_batch_size, + req.avg_batch_time, req.batches_per_epoch, req.epochs, req.physical_nodes, req.devices, + req.node_network_bandwidth, req.workers, req.canonical_nodes, req.predownload, + req.cache_limit, req.shuffle_algo, req.shuffle_block_size, req.seed) + + plots_buffer = plot_simulation(step_times, shard_downloads) + + if plots_buffer is not None: + base64_encoded_image = base64.b64encode(plots_buffer).decode("utf-8") + return {"image": base64_encoded_image} + else: + raise ValueError("plot_simulation returned None. Set web=True to return bytes.") diff --git a/scripts/simulation/static/loading.gif b/scripts/simulation/static/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..b789b68c08554ce0d93a569d2608b0d0276cb674 GIT binary patch literal 76341 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 Hge?9aBQ={) literal 0 HcmV?d00001 From 128214c256a35015c2c6b5c9361bed09a1bc1321 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Thu, 17 Aug 2023 14:58:37 -0700 Subject: [PATCH 02/31] pylint fixes --- scripts/simulation/last_used_ordered_set.py | 7 ++-- scripts/simulation/simulation_funcs.py | 3 +- scripts/simulation/simulation_script.py | 44 +++++++++++---------- scripts/simulation/simulation_web.py | 33 ++++++++++------ 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/scripts/simulation/last_used_ordered_set.py b/scripts/simulation/last_used_ordered_set.py index 25bb8d4e4..185817a7d 100644 --- a/scripts/simulation/last_used_ordered_set.py +++ b/scripts/simulation/last_used_ordered_set.py @@ -26,14 +26,13 @@ def setitem(self, key: Any, move_to_end: bool = True): self.move_to_end(key, last=move_to_end) def popLRU(self): - """Pop the least recently used item (located at the front). - """ + """Pop the least recently used item (located at the front).""" self.popitem(last=False)[0] - + def setuse(self, key: Any): """Mark an item as used, moving it to the end. Args: key (Any): key of element to move to the end, signifying most recent access. """ - self.setitem(key) \ No newline at end of file + self.setitem(key) diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index 41413de1a..eb7510d05 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -40,6 +40,7 @@ def simulate(shards: int, """Simulates step time and downloads using streaming for the specified input parameters. Key Notes and Assumptions: + * assume that batch time is solely made up of two things: batch processing time and batch shard download wait time * loop through workers round-robin style for batches and for downloads * assume each node has a separate network bandwidth @@ -76,7 +77,6 @@ def simulate(shards: int, step_times (NDArray): time taken by each step, calculated by simulation. shard_downloads (NDArray): amount of downloaded bytes at each step, calculated by simulation. """ - # simulation preparation... # we assume that each shard is going to be seen only once. Not handling up/down-sampling @@ -385,7 +385,6 @@ def plot_simulation(step_times: NDArray, Returns: Optional[bytes]: bytes of plot image if ``web`` is ``True``, else plot is displayed, and returns ``None``. """ - import matplotlib if web: matplotlib.use('agg') diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index 45ff08345..273669596 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -5,44 +5,46 @@ import os.path import sys + sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from simulation_funcs import simulate, plot_simulation +from simulation_funcs import plot_simulation, simulate # Input Parameters # dataset -shards = 20000 # number of shards -samples_per_shard = 4000 # number of samples per shard -avg_shard_size = 1.6e7 # average shard size (bytes) +shards = 20000 # number of shards +samples_per_shard = 4000 # number of samples per shard +avg_shard_size = 1.6e7 # average shard size (bytes) # training -device_batch_size = 16 # device batch size (samples) -avg_batch_time = 0.27 # average batch processing time (seconds) +device_batch_size = 16 # device batch size (samples) +avg_batch_time = 0.27 # average batch processing time (seconds) batches_per_epoch = 200 epochs = 1 # streaming -workers = 8 # number of workers per device -canonical_nodes = 2 # number of canonical nodes -predownload = 3800 # number of samples to predownload per worker (samples) -cache_limit = None # cache limit per node (bytes) -shuffle_algo = 'py1b' # shuffling algorithm -shuffle_block_size = 16000000 # shuffling block size (samples) -seed = 17 # random seed +workers = 8 # number of workers per device +canonical_nodes = 2 # number of canonical nodes +predownload = 3800 # number of samples to predownload per worker (samples) +cache_limit = None # cache limit per node (bytes) +shuffle_algo = 'py1b' # shuffling algorithm +shuffle_block_size = 16000000 # shuffling block size (samples) +seed = 17 # random seed # hardware and network -physical_nodes = 2 # number of physical nodes -devices = 8 # number of devices per node -node_network_bandwidth = 5e8 # network bandwidth per node (bytes/s) +physical_nodes = 2 # number of physical nodes +devices = 8 # number of devices per node +node_network_bandwidth = 5e8 # network bandwidth per node (bytes/s) # ---------------------------------------------- # # simulate step times and shard downloads given the inputs -step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, device_batch_size, - avg_batch_time, batches_per_epoch, epochs, physical_nodes, devices, - node_network_bandwidth, workers, canonical_nodes, predownload, - cache_limit, shuffle_algo, shuffle_block_size, seed) +step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, + device_batch_size, avg_batch_time, batches_per_epoch, + epochs, physical_nodes, devices, node_network_bandwidth, + workers, canonical_nodes, predownload, cache_limit, + shuffle_algo, shuffle_block_size, seed) # plot results -plot_simulation(step_times, shard_downloads, web=False) \ No newline at end of file +plot_simulation(step_times, shard_downloads, web=False) diff --git a/scripts/simulation/simulation_web.py b/scripts/simulation/simulation_web.py index 90d90e099..1dc2f551b 100644 --- a/scripts/simulation/simulation_web.py +++ b/scripts/simulation/simulation_web.py @@ -14,15 +14,17 @@ import os.path import sys + sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import base64 +from typing import Optional + from fastapi import FastAPI from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from typing import Optional -import base64 -from simulation.simulation_funcs import simulate, plot_simulation +from simulation.simulation_funcs import plot_simulation, simulate INDEX = ''' @@ -280,18 +282,20 @@ current_file_dir = current_file.parent project_root = current_file_dir.parent project_root_absolute = project_root.resolve() -static_root_absolute = project_root_absolute / "simulation/static" +static_root_absolute = project_root_absolute / 'simulation/static' app = FastAPI() # mount static file directory for the nice loading gif :) -app.mount("/static", StaticFiles(directory=static_root_absolute), name="static") +app.mount('/static', StaticFiles(directory=static_root_absolute), name='static') + @app.get('/') def get_root() -> HTMLResponse: """Get the index HTML file.""" return HTMLResponse(INDEX) + class GetSimulationRequest(BaseModel): """simulation input parameters.""" shards: int @@ -312,6 +316,7 @@ class GetSimulationRequest(BaseModel): shuffle_block_size: int = 1 << 18 seed: int = 42 + @app.post('/api/simulate') def post_api_simulate(req: GetSimulationRequest) -> dict: """Serve a POST request to simulate a run. @@ -322,15 +327,17 @@ def post_api_simulate(req: GetSimulationRequest) -> dict: Returns: dict: JSON object containing the base64 image string for the simulation plots. """ - step_times, shard_downloads = simulate(req.shards, req.samples_per_shard, req.avg_shard_size, req.device_batch_size, - req.avg_batch_time, req.batches_per_epoch, req.epochs, req.physical_nodes, req.devices, - req.node_network_bandwidth, req.workers, req.canonical_nodes, req.predownload, - req.cache_limit, req.shuffle_algo, req.shuffle_block_size, req.seed) - + step_times, shard_downloads = simulate(req.shards, req.samples_per_shard, req.avg_shard_size, + req.device_batch_size, req.avg_batch_time, + req.batches_per_epoch, req.epochs, req.physical_nodes, + req.devices, req.node_network_bandwidth, req.workers, + req.canonical_nodes, req.predownload, req.cache_limit, + req.shuffle_algo, req.shuffle_block_size, req.seed) + plots_buffer = plot_simulation(step_times, shard_downloads) if plots_buffer is not None: - base64_encoded_image = base64.b64encode(plots_buffer).decode("utf-8") - return {"image": base64_encoded_image} + base64_encoded_image = base64.b64encode(plots_buffer).decode('utf-8') + return {'image': base64_encoded_image} else: - raise ValueError("plot_simulation returned None. Set web=True to return bytes.") + raise ValueError('plot_simulation returned None. Set web=True to return bytes.') From 4bc747fbb61cdcd754611a2febffbed9537acc03 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Thu, 17 Aug 2023 15:39:44 -0700 Subject: [PATCH 03/31] more pylint fixes --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index b452b453b..4b5f929c6 100644 --- a/setup.py +++ b/setup.py @@ -78,6 +78,7 @@ 'pydantic==2.1.1', 'uvicorn==0.23.2', 'pytest-split==0.8.1', + 'sortedcollections==2.1.0', ] extra_deps['docs'] = [ From 661d11844596f542ff19313bb90946aa1e031e0f Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 22 Aug 2023 22:50:04 -0700 Subject: [PATCH 04/31] simulation bug fixing with cache lim --- scripts/simulation/simulation_funcs.py | 11 ++++++++--- scripts/simulation/simulation_script.py | 22 +++++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index eb7510d05..c4129d39b 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -138,8 +138,8 @@ def simulate(shards: int, worker_download_indices = np.array( [0] * physical_nodes ) # track which worker we are on for downloading, per node, round-robin style - node_partial_shards = np.array( - [0] * physical_nodes) # track partial shard downloads at each node + node_partial_shards = np.array([0] * physical_nodes).astype( + np.float32) # track partial shard downloads at each node # construct download shard OrderedSets for every worker # list of lists of OrderedSets. outer list is per node, inner list is per worker-device @@ -174,7 +174,7 @@ def simulate(shards: int, # track how many shards we downloaded in this batch total num_downloads = 0 - # get current samples and predownload samples for each node, for this batch + # get current samples and download samples for each node, for this batch for physical_node in range(physical_nodes): curr_batch_samples = samples_per_worker[physical_node, :, curr_worker, worker_sample_index:worker_sample_index + @@ -260,6 +260,8 @@ def simulate(shards: int, physical_node] + avg_shard_size > cache_limit: # evict the LRU shard node_shards[physical_node].popLRU() + # update the node cache usage + node_cache_usage[physical_node] -= avg_shard_size num_batch_shards += 1 num_downloads += 1 node_cache_usage[physical_node] += avg_shard_size @@ -299,6 +301,7 @@ def simulate(shards: int, # get number of bytes/shards/remainder we can download in predownload_time download_bytes_left = node_network_bandwidth * download_time_left + # number of shards we can download right now -- # add in the fractional part of shard that may have been downloading from previous step download_shards_left = ( @@ -336,6 +339,8 @@ def simulate(shards: int, physical_node] + avg_shard_size > cache_limit: # evict the LRU shard node_shards[physical_node].popLRU() + # update the node cache usage + node_cache_usage[physical_node] -= avg_shard_size num_downloads += 1 node_cache_usage[physical_node] += avg_shard_size # add this shard to node_shards for the node that the worker is on diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index 273669596..f3544c1d7 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -14,28 +14,28 @@ # dataset shards = 20000 # number of shards -samples_per_shard = 4000 # number of samples per shard -avg_shard_size = 1.6e7 # average shard size (bytes) +samples_per_shard = 4093 # number of samples per shard +avg_shard_size = 15962700 # average shard size (bytes) # training -device_batch_size = 16 # device batch size (samples) -avg_batch_time = 0.27 # average batch processing time (seconds) -batches_per_epoch = 200 epochs = 1 +batches_per_epoch = 5000 +device_batch_size = 4 # device batch size (samples) +avg_batch_time = 0.27 # average batch processing time (seconds) # streaming workers = 8 # number of workers per device -canonical_nodes = 2 # number of canonical nodes -predownload = 3800 # number of samples to predownload per worker (samples) -cache_limit = None # cache limit per node (bytes) +canonical_nodes = 1 # number of canonical nodes +predownload = 16 # number of samples to predownload per worker (samples) +cache_limit = 399067500 # cache limit per node (bytes) shuffle_algo = 'py1b' # shuffling algorithm -shuffle_block_size = 16000000 # shuffling block size (samples) +shuffle_block_size = 102325 # shuffling block size (samples) seed = 17 # random seed # hardware and network -physical_nodes = 2 # number of physical nodes +physical_nodes = 1 # number of physical nodes devices = 8 # number of devices per node -node_network_bandwidth = 5e8 # network bandwidth per node (bytes/s) +node_network_bandwidth = 1e8 # network bandwidth per node (bytes/s) # ---------------------------------------------- # From 6ef391da98054f96306133a6efea432d5435e23e Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 23 Aug 2023 12:16:34 -0700 Subject: [PATCH 05/31] added py1e to simulator --- scripts/simulation/simulation_script.py | 14 +-- streaming/base/shuffle/__init__.py | 2 + streaming/base/shuffle/py1e.py | 121 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 streaming/base/shuffle/py1e.py diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index f3544c1d7..42bbf61a6 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -19,21 +19,21 @@ # training epochs = 1 -batches_per_epoch = 5000 -device_batch_size = 4 # device batch size (samples) +batches_per_epoch = 20000 +device_batch_size = 16 # device batch size (samples) avg_batch_time = 0.27 # average batch processing time (seconds) # streaming workers = 8 # number of workers per device -canonical_nodes = 1 # number of canonical nodes -predownload = 16 # number of samples to predownload per worker (samples) -cache_limit = 399067500 # cache limit per node (bytes) +canonical_nodes = 4 # number of canonical nodes +predownload = 64 # number of samples to predownload per worker (samples) +cache_limit = 800000000 # cache limit per node (bytes) shuffle_algo = 'py1b' # shuffling algorithm -shuffle_block_size = 102325 # shuffling block size (samples) +shuffle_block_size = 100000 # shuffling block size (samples) seed = 17 # random seed # hardware and network -physical_nodes = 1 # number of physical nodes +physical_nodes = 2 # number of physical nodes devices = 8 # number of devices per node node_network_bandwidth = 1e8 # network bandwidth per node (bytes/s) diff --git a/streaming/base/shuffle/__init__.py b/streaming/base/shuffle/__init__.py index 286f34bd2..fe289eac9 100644 --- a/streaming/base/shuffle/__init__.py +++ b/streaming/base/shuffle/__init__.py @@ -10,12 +10,14 @@ from streaming.base.shuffle.py1b import get_shuffle_py1b from streaming.base.shuffle.py1s import get_shuffle_py1s from streaming.base.shuffle.py2s import get_shuffle_py2s +from streaming.base.shuffle.py1e import get_shuffle_py1e algos = { 'py1b': get_shuffle_py1b, 'py1s': get_shuffle_py1s, 'py2s': get_shuffle_py2s, 'naive': get_shuffle_naive, + 'py1e': get_shuffle_py1e, } diff --git a/streaming/base/shuffle/py1e.py b/streaming/base/shuffle/py1e.py new file mode 100644 index 000000000..eb00bd7c8 --- /dev/null +++ b/streaming/base/shuffle/py1e.py @@ -0,0 +1,121 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Shuffling algorithm that shuffles intra-shard in one place. + +This algorithm is roughly twice as fast as algorithm ``py2s``, and ever so slightly biased. + +Bias in this case merely refers to how we assign samples when we split shards at canonical node +boundaries, which is non-random in this algorithm. In practice, we found this does not matter to +convergence, while making us faster. +""" + +import numpy as np +from numpy.typing import NDArray + +from streaming.base.shuffle.py1s import divide_spans + + +def get_shuffle_py1e(shard_sizes: NDArray[np.int64], + num_canonical_nodes: int, + seed: int, + epoch: int, + block_size: int = 1 << 18) -> NDArray[np.int64]: + """Get the shuffled global ordering of samples for an epoch. + + The assignment of shards to nodes is fixed across epochs, but each grouping of shards is + processed concurrently in a different order by each node's workers each epoch. + + Args: + shard_sizes (NDArray[np.int64]): Number of samples contained in each shard, in order. + num_canonical_nodes (int): Number of canonical nodes. + seed (int): Base random seed, which is held constant over an entire training run. + epoch (int): Current epoch, which is added to the seed to get a different deterministic + shuffle each epoch. + block_size (int): Unit of shuffle, used to set the std and clip length for the gaussian + noise to be added to each shard. Defaults to ``1 << 18``. + + Returns: + NDArray[np.int64]: 1:1 mapping of sample ID to shuffled sample ID. + """ + # Create each shard's sample ID span (begin, end excl). + # also get max shard size to calculate the + spans = [] + num_samples = 0 + max_shard_size = 0 + for shard_size in shard_sizes: + span = num_samples, num_samples + shard_size + spans.append(span) + num_samples += shard_size + if shard_size > max_shard_size: + max_shard_size = shard_size + + # Generate the initial ordering of shards, which is fixed over an entire training run. + run_rng = np.random.default_rng(seed) + run_rng.shuffle(spans) + + # Break the shard spans at canonical node boundaries. + # super_spans are the indices of spans that correspond to each canonical node + spans, super_spans = divide_spans(spans, num_samples, num_canonical_nodes) + + # Shuffle the span ordering within each canonical node uniquely to this epoch. + epoch_rng = np.random.default_rng(seed + epoch) + for begin, end in super_spans: + # retrieving the spans (shard parts) associated with this canonical node + part = spans[begin:end] + epoch_rng.shuffle(part) # pyright: ignore + spans[begin:end] = part + + # Populate the global sample ID mapping, shuffling within each span. + ids = np.empty(num_samples, np.int64) + offset = 0 + # iterate through each canonical node's spans because we don't want samples crossing canonical node boundaries + for cn_begin, cn_end in super_spans: + cn_spans = spans[cn_begin:cn_end] + cn_span_sizes = np.array([end - begin for begin, end in cn_spans]) + num_cn_samples = cn_span_sizes.sum() + # the spans of a canonical node are shuffled, so they have sample ids that are + # not contiguous. need to get the correct sample ids for the current canonical node + cn_samples = np.empty(num_cn_samples) + samples_inserted = 0 + for begin, end in cn_spans: + # insert span samples into cn_samples array + cn_span_samples = np.arange(begin, end) + epoch_rng.shuffle(cn_span_samples) + cn_samples[samples_inserted:samples_inserted + (end - begin)] = cn_span_samples + samples_inserted += (end - begin) + + # iterate over each span and shift sample indices by gaussian noise + cn_sample_offset = 0 + shifted_samples = np.arange(num_cn_samples).astype(np.float64) + for span_size in cn_span_sizes: + + # cutoff is (block_size - span_size)/2, so the span samples + # are only found in a range of maximum possible size block_size + cutoff = (block_size - span_size)/2 + + # make sure the lower bound doesn't cross the start of the canonical node + lower_bound = max(-cutoff, -cn_sample_offset) + # make sure the upper bound doesn't cross the end of the canonical node + upper_bound = min(cutoff, num_cn_samples - cn_sample_offset - span_size) + # sample shifts from uniform distribution + shifts = epoch_rng.uniform(low=lower_bound, high=upper_bound, size=span_size) + + # add shifts to shard samples + shifted_samples[cn_sample_offset:cn_sample_offset+span_size] += shifts + + # update offset for next shard + cn_sample_offset += span_size + + # get incides that would sort the shifted_samples array + sort_indices = np.argsort(shifted_samples) + + # apply the sorting to the samples for our canonical node + cn_samples = cn_samples[sort_indices] + + # assign the gaussian "shuffled" samples to the global ids array + ids[offset:offset + num_cn_samples] = cn_samples + + offset += num_cn_samples + + return ids From 22fccef58f0eb038dd2932bdd12ffaf2e9505c53 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 23 Aug 2023 20:54:12 -0700 Subject: [PATCH 06/31] throughput scales as device batch size decreases --- scripts/simulation/simulation_funcs.py | 11 +++- scripts/simulation/simulation_script.py | 20 +++--- scripts/simulation/simulation_testing.py | 80 ++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 scripts/simulation/simulation_testing.py diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index c4129d39b..cb460c538 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -24,7 +24,7 @@ def simulate(shards: int, samples_per_shard: int, avg_shard_size: float, device_batch_size: int, - avg_batch_time: float, + time_per_sample: float, batches_per_epoch: int, epochs: int, physical_nodes: int, @@ -33,6 +33,7 @@ def simulate(shards: int, workers: int, canonical_nodes: int, predownload: int, + avg_compressed_shard_size: Optional[float] = None, cache_limit: Optional[int] = None, shuffle_algo: Optional[str] = None, shuffle_block_size: int = 1 << 18, @@ -58,8 +59,9 @@ def simulate(shards: int, shards (int): number of shards samples_per_shard (int): number of samples per shard avg_shard_size (float): average shard size (bytes) + avg_compressed_shard_size (float): average compressed_shard size (bytes) device_batch_size (int): device batch size (samples) - avg_batch_time (float): average batch processing time (seconds) + time_per_sample (float): time to process one sample on one device (seconds) batches_per_epoch (int): number of batches per epoch epochs (int): number of epochs physical_nodes (int): number of physical nodes @@ -93,6 +95,9 @@ def simulate(shards: int, workers_per_rank=workers, batch_size=device_batch_size, drop_first=0) + + # time for the global batch is just device batch size * time per sample, since all devices process their microbatch in parallel + avg_batch_time = device_batch_size * time_per_sample # simulate training! @@ -371,6 +376,8 @@ def simulate(shards: int, step_times = np.array(step_times) shard_downloads = avg_shard_size * np.array(shard_downloads) + if avg_compressed_shard_size: + shard_downloads = shard_downloads * avg_compressed_shard_size / avg_shard_size return step_times, shard_downloads diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index 42bbf61a6..e6285fc01 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -13,38 +13,40 @@ # Input Parameters # dataset -shards = 20000 # number of shards +shards = 20850 # number of shards samples_per_shard = 4093 # number of samples per shard -avg_shard_size = 15962700 # average shard size (bytes) +avg_shard_size = 67092639 # average shard size (bytes) +avg_compressed_shard_size = 16000000 # average compressed shard size (bytes) +avg_shard_size = 32000000 # training epochs = 1 -batches_per_epoch = 20000 +batches_per_epoch = 4000 device_batch_size = 16 # device batch size (samples) -avg_batch_time = 0.27 # average batch processing time (seconds) +time_per_sample = 0.018 # time to process one sample on one device (seconds) # streaming workers = 8 # number of workers per device canonical_nodes = 4 # number of canonical nodes predownload = 64 # number of samples to predownload per worker (samples) cache_limit = 800000000 # cache limit per node (bytes) -shuffle_algo = 'py1b' # shuffling algorithm +shuffle_algo = 'py1e' # shuffling algorithm shuffle_block_size = 100000 # shuffling block size (samples) seed = 17 # random seed # hardware and network physical_nodes = 2 # number of physical nodes devices = 8 # number of devices per node -node_network_bandwidth = 1e8 # network bandwidth per node (bytes/s) +node_network_bandwidth = 1e9 # network bandwidth per node (bytes/s) # ---------------------------------------------- # # simulate step times and shard downloads given the inputs step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, - device_batch_size, avg_batch_time, batches_per_epoch, + device_batch_size, time_per_sample, batches_per_epoch, epochs, physical_nodes, devices, node_network_bandwidth, - workers, canonical_nodes, predownload, cache_limit, - shuffle_algo, shuffle_block_size, seed) + workers, canonical_nodes, predownload, avg_compressed_shard_size, + cache_limit, shuffle_algo, shuffle_block_size, seed) # plot results plot_simulation(step_times, shard_downloads, web=False) diff --git a/scripts/simulation/simulation_testing.py b/scripts/simulation/simulation_testing.py new file mode 100644 index 000000000..8e8be84c8 --- /dev/null +++ b/scripts/simulation/simulation_testing.py @@ -0,0 +1,80 @@ +import wandb +import matplotlib.pyplot as plt +import numpy as np +import yaml +from simulation_funcs import simulate + + +run_ids = ["py1e-testing/1pid105q"] + +# common parameters +shards = 20850 +samples_per_shard = 4093 +avg_shard_size = 67092639 +compressed_shard_size = 16000000 +compression_ratio = compressed_shard_size/avg_shard_size +epochs = 1 +avg_batch_time = 0.28 +node_network_bandwidth = 1e8 +throughput_window = 10 + + +api = wandb.Api() + +for run_id in run_ids: + + run = api.run("mosaic-ml/"+run_id) + summary = run.summary + config = run.config + + # get parameters from run config and summary + batches_per_epoch = summary['_step'] + devices = int(config["num_gpus_per_node"]) + physical_nodes = int(config['n_gpus']/devices) + # device_batch_size set for each run + device_batch_size = int(config['global_train_batch_size']/(physical_nodes*devices)) + canonical_nodes = int(config['num_canonical_nodes']) + workers = int(config["train_loader"]["num_workers"]) + predownload = int(config["train_loader"]["dataset"]["predownload"]) + cache_limit = None + if "cache_limit" in config["train_loader"]["dataset"]: + cache_limit = int(config["train_loader"]["dataset"]["cache_limit"]) + shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] + shuffle_block_size = int(config["train_loader"]["dataset"]["shuffle_block_size"]) + seed = config['seed'] + + print(yaml.dump(config, default_flow_style=False)) + + # get real throughput and network use from the run + real_batch_throughput = run.history(samples=batches_per_epoch-throughput_window, keys=['throughput/batches_per_sec'], pandas=True) + real_network_use = run.history(stream="system", pandas=True)['system.network.recv'] + + # simulate throughput and network use given the inputs + + step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, + device_batch_size, avg_batch_time, batches_per_epoch, + epochs, physical_nodes, devices, node_network_bandwidth, + workers, canonical_nodes, predownload, cache_limit, + shuffle_algo, shuffle_block_size, seed) + + immediate_batch_throughput = 1 / step_times + + shard_downloads_cumulative = np.cumsum(shard_downloads) + + step_times_rolling_avg = np.convolve(step_times, np.ones(throughput_window) / throughput_window, mode='valid') + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + batch_throughput_rolling_avg = np.concatenate((np.array([0] * 9), batch_throughput_rolling_avg)) + + fig, (ax1, ax2) = plt.subplots(2, 1) + + ax1.set_title("throughput") + #ax1.plot(real_batch_throughput["_step"], real_batch_throughput["throughput/batches_per_sec"], color="red", label="real") + ax1.plot(np.arange(batch_throughput_rolling_avg.shape[0]), batch_throughput_rolling_avg, color="blue", label="sim") + ax1.legend() + + ax2.set_title("network use") + #ax2.plot(list(range(len(real_network_use))), real_network_use, color="red", label="real") + ax2.plot(np.arange(shard_downloads_cumulative.shape[0]), shard_downloads_cumulative, color="blue", label="sim") + ax2.legend() + + plt.show() \ No newline at end of file From d8745236e7a37176fa35f17703f4899f55e6866f Mon Sep 17 00:00:00 2001 From: Saaketh Date: Thu, 24 Aug 2023 11:24:13 -0700 Subject: [PATCH 07/31] script changes --- scripts/simulation/simulation_script.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index e6285fc01..fd3398f99 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -17,7 +17,6 @@ samples_per_shard = 4093 # number of samples per shard avg_shard_size = 67092639 # average shard size (bytes) avg_compressed_shard_size = 16000000 # average compressed shard size (bytes) -avg_shard_size = 32000000 # training epochs = 1 @@ -28,16 +27,16 @@ # streaming workers = 8 # number of workers per device canonical_nodes = 4 # number of canonical nodes -predownload = 64 # number of samples to predownload per worker (samples) +predownload = 1 # number of samples to predownload per worker (samples) cache_limit = 800000000 # cache limit per node (bytes) -shuffle_algo = 'py1e' # shuffling algorithm +shuffle_algo = 'py1b' # shuffling algorithm shuffle_block_size = 100000 # shuffling block size (samples) seed = 17 # random seed # hardware and network physical_nodes = 2 # number of physical nodes devices = 8 # number of devices per node -node_network_bandwidth = 1e9 # network bandwidth per node (bytes/s) +node_network_bandwidth = 2e9 # network bandwidth per node (bytes/s) # ---------------------------------------------- # From ef02aa81637093d5d9925e83de15a38c7ae607a1 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Sat, 26 Aug 2023 12:39:27 -0700 Subject: [PATCH 08/31] new UI, simulator as generator, simulation testing --- Makefile | 2 +- scripts/simulation/simulation_funcs.py | 76 +++-- scripts/simulation/simulation_script.py | 23 +- scripts/simulation/simulation_testing.py | 90 ++++-- scripts/simulation/simulation_ui.py | 159 +++++++++++ scripts/simulation/simulation_web.py | 343 ----------------------- scripts/simulation/static/loading.gif | Bin 76341 -> 0 bytes setup.py | 2 + streaming/base/shuffle/__init__.py | 2 + streaming/base/shuffle/py1br.py | 91 ++++++ 10 files changed, 380 insertions(+), 408 deletions(-) create mode 100644 scripts/simulation/simulation_ui.py delete mode 100644 scripts/simulation/simulation_web.py delete mode 100644 scripts/simulation/static/loading.gif create mode 100644 streaming/base/shuffle/py1br.py diff --git a/Makefile b/Makefile index aa246f915..e050c9d25 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,6 @@ web: uvicorn scripts.partition.web:app --port 1337 --reload simulation: - uvicorn scripts.simulation.simulation_web:app --port 2000 --reload + streamlit run scripts/simulation/simulation_ui.py .PHONY: test lint style diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index cb460c538..5c112acce 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -9,7 +9,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from io import BytesIO -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import numpy as np from numpy.typing import NDArray @@ -18,26 +18,27 @@ from streaming.base.partition import get_partitions from streaming.base.shuffle import get_shuffle +from streaming.base.util import bytes_to_int, number_abbrev_to_int def simulate(shards: int, - samples_per_shard: int, - avg_shard_size: float, + samples_per_shard: Union[int, str], + avg_shard_size: Union[float, str], device_batch_size: int, time_per_sample: float, - batches_per_epoch: int, + batches_per_epoch: Union[int, str], epochs: int, physical_nodes: int, devices: int, - node_network_bandwidth: float, + node_network_bandwidth: Union[float,str], workers: int, canonical_nodes: int, - predownload: int, - avg_compressed_shard_size: Optional[float] = None, - cache_limit: Optional[int] = None, + predownload: Union[int, str], + cache_limit: Union[int, str, None] = None, shuffle_algo: Optional[str] = None, - shuffle_block_size: int = 1 << 18, - seed: int = 42) -> Tuple[NDArray, NDArray]: + shuffle_block_size: Union[int, str] = 1 << 18, + seed: int = 42, + generator: bool = False) -> Union[Tuple[int, int], Tuple[NDArray, NDArray]]: """Simulates step time and downloads using streaming for the specified input parameters. Key Notes and Assumptions: @@ -57,23 +58,23 @@ def simulate(shards: int, Args: shards (int): number of shards - samples_per_shard (int): number of samples per shard - avg_shard_size (float): average shard size (bytes) - avg_compressed_shard_size (float): average compressed_shard size (bytes) + samples_per_shard (Union[int, str]): number of samples per shard + avg_shard_size (Union[float, str]): average shard size (bytes) device_batch_size (int): device batch size (samples) time_per_sample (float): time to process one sample on one device (seconds) - batches_per_epoch (int): number of batches per epoch + batches_per_epoch (Union[int, str]): number of batches per epoch epochs (int): number of epochs physical_nodes (int): number of physical nodes devices (int): number of devices per node - node_network_bandwidth (float): network bandwidth per node (bytes/s) + node_network_bandwidth (Union[float, str]): network bandwidth per node (bytes/s) workers (int): number of workers per device canonical_nodes (int): number of canonical nodes - predownload (int): number of samples to predownload per worker (samples) - cache_limit (int, optional): cache limit per node (bytes). Defaults to ``None``. + predownload (Union[int, str]): number of samples to predownload per worker (samples) + cache_limit (Union[int, str, None]): cache limit per node (bytes). Defaults to ``None``. shuffle_algo (str, optional): shuffling algorithm. Defaults to ``None``. - shuffle_block_size (int): shuffling block size (samples). Defaults to ``1 << 18``. + shuffle_block_size (Union[int, str]): shuffling block size (samples). Defaults to ``1 << 18``. seed (int): shuffling seed. Defaults to ``42``. + generator (bool): True if we yield throughput and shard_download one step at a time. Returns: step_times (NDArray): time taken by each step, calculated by simulation. @@ -81,13 +82,23 @@ def simulate(shards: int, """ # simulation preparation... + # make sure potential string args are usable + samples_per_shard = number_abbrev_to_int(samples_per_shard) + avg_shard_size = bytes_to_int(avg_shard_size) + batches_per_epoch = number_abbrev_to_int(batches_per_epoch) + node_network_bandwidth = bytes_to_int(node_network_bandwidth) + predownload = number_abbrev_to_int(predownload) + shuffle_block_size = number_abbrev_to_int(shuffle_block_size) + if cache_limit: + cache_limit = bytes_to_int(cache_limit) + # we assume that each shard is going to be seen only once. Not handling up/down-sampling # or multiple streams for now. shard_sizes = np.array([samples_per_shard] * shards) # get partition of sample ids # structured as (physical nodes, ranks per node, workers per rank, batches per worker, batch size) - partitions = get_partitions(algo='orig', + orig_partitions = get_partitions(algo='orig', num_samples=shards * samples_per_shard, num_canonical_nodes=canonical_nodes, num_physical_nodes=physical_nodes, @@ -124,6 +135,9 @@ def simulate(shards: int, for epoch in range(epochs): + print("shards in node 0:", len(node_shards[0])) + print("shards in node 1:", len(node_shards[1])) + if shuffle_algo is not None: # get shuffle of sample ids shuffle = get_shuffle(algo=shuffle_algo, @@ -133,12 +147,15 @@ def simulate(shards: int, epoch=epoch, block_size=shuffle_block_size) # index into the shuffle to get the new sample at each index - partitions = np.where(partitions != -1, shuffle[partitions], -1) + partitions = np.where(orig_partitions != -1, shuffle[orig_partitions], -1) # handle initial predownload # reshape shuffled_partition to get samples, in order, per worker samples_per_worker = partitions.reshape(physical_nodes, devices, workers, -1) + print("shards needed for node 0:", set(sample_to_shard[partitions[0]].flatten())) + print("shards needed for node 1:", set(sample_to_shard[partitions[1]].flatten())) + worker_sample_index = 0 # track which sample we are on. is an index per worker. worker_download_indices = np.array( [0] * physical_nodes @@ -367,19 +384,20 @@ def simulate(shards: int, # update worker download index for this node worker_download_indices[physical_node] = curr_worker_download_index - step_times.append(slowest_download_time + avg_batch_time) - shard_downloads.append(num_downloads) + if generator: + yield (slowest_download_time + avg_batch_time, avg_shard_size*num_downloads) + else: + step_times.append(slowest_download_time + avg_batch_time) + shard_downloads.append(avg_shard_size*num_downloads) # if we are at last worker, then the sample_index per worker should shift ahead by device_batch_size if curr_worker == workers - 1: worker_sample_index += device_batch_size - - step_times = np.array(step_times) - shard_downloads = avg_shard_size * np.array(shard_downloads) - if avg_compressed_shard_size: - shard_downloads = shard_downloads * avg_compressed_shard_size / avg_shard_size - - return step_times, shard_downloads + + if not generator: + step_times = np.array(step_times) + shard_downloads = np.array(shard_downloads) + yield step_times, shard_downloads def plot_simulation(step_times: NDArray, diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py index fd3398f99..d63f954b5 100644 --- a/scripts/simulation/simulation_script.py +++ b/scripts/simulation/simulation_script.py @@ -16,36 +16,37 @@ shards = 20850 # number of shards samples_per_shard = 4093 # number of samples per shard avg_shard_size = 67092639 # average shard size (bytes) -avg_compressed_shard_size = 16000000 # average compressed shard size (bytes) # training epochs = 1 -batches_per_epoch = 4000 +batches_per_epoch = 3000 device_batch_size = 16 # device batch size (samples) -time_per_sample = 0.018 # time to process one sample on one device (seconds) # streaming workers = 8 # number of workers per device -canonical_nodes = 4 # number of canonical nodes -predownload = 1 # number of samples to predownload per worker (samples) -cache_limit = 800000000 # cache limit per node (bytes) +canonical_nodes = 128 # number of canonical nodes +predownload = 3800 # number of samples to predownload per worker (samples) shuffle_algo = 'py1b' # shuffling algorithm -shuffle_block_size = 100000 # shuffling block size (samples) -seed = 17 # random seed +cache_limit = None # cache limit (bytes) +shuffle_block_size = 1000000 # shuffling block size (samples) +seed = 18 # random seed # hardware and network physical_nodes = 2 # number of physical nodes devices = 8 # number of devices per node +time_per_sample = 0.0175 # time to process one sample on one device (seconds) node_network_bandwidth = 2e9 # network bandwidth per node (bytes/s) # ---------------------------------------------- # # simulate step times and shard downloads given the inputs -step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, +results = simulate(shards, samples_per_shard, avg_shard_size, device_batch_size, time_per_sample, batches_per_epoch, epochs, physical_nodes, devices, node_network_bandwidth, - workers, canonical_nodes, predownload, avg_compressed_shard_size, - cache_limit, shuffle_algo, shuffle_block_size, seed) + workers, canonical_nodes, predownload, cache_limit, + shuffle_algo, shuffle_block_size, seed) + +step_times, shard_downloads = next(results) # plot results plot_simulation(step_times, shard_downloads, web=False) diff --git a/scripts/simulation/simulation_testing.py b/scripts/simulation/simulation_testing.py index 8e8be84c8..c47df2fe0 100644 --- a/scripts/simulation/simulation_testing.py +++ b/scripts/simulation/simulation_testing.py @@ -1,32 +1,53 @@ +# testing script for simulator +# compares experimental data from wandb with simulation data. + import wandb import matplotlib.pyplot as plt import numpy as np import yaml from simulation_funcs import simulate +import pandas as pd + +api = wandb.Api() +project_id = "mosaic-ml/streaming-shuffling-algo" +project_runs = api.runs(path=project_id, per_page=300) +project_runs_list = [run.id for run in project_runs] +skip = 0 -run_ids = ["py1e-testing/1pid105q"] -# common parameters +# C4 neox compressed from OCI parameters shards = 20850 samples_per_shard = 4093 avg_shard_size = 67092639 compressed_shard_size = 16000000 -compression_ratio = compressed_shard_size/avg_shard_size +compression_ratio = compressed_shard_size / avg_shard_size epochs = 1 -avg_batch_time = 0.28 -node_network_bandwidth = 1e8 +time_per_sample = 0.0175 +node_network_bandwidth = 2e9 throughput_window = 10 +def get_similarity_percentage(real, sim): + real_copy = real.reshape(1, -1) + sim_copy = sim.reshape(1, -1) + merged = np.concatenate((real_copy, sim_copy), axis=0) + similarities = np.abs(real-sim)/np.max(merged, axis=0) + nanmean = np.nanmean(similarities) + return 1 - nanmean -api = wandb.Api() +for run_id in project_runs_list[skip:]: + + run = api.run(f"{project_id}/{run_id}") -for run_id in run_ids: + print(run.name) - run = api.run("mosaic-ml/"+run_id) summary = run.summary config = run.config + if '_step' not in summary: + print("skipping unsuccessful run") + continue + # get parameters from run config and summary batches_per_epoch = summary['_step'] devices = int(config["num_gpus_per_node"]) @@ -38,21 +59,26 @@ predownload = int(config["train_loader"]["dataset"]["predownload"]) cache_limit = None if "cache_limit" in config["train_loader"]["dataset"]: - cache_limit = int(config["train_loader"]["dataset"]["cache_limit"]) - shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] - shuffle_block_size = int(config["train_loader"]["dataset"]["shuffle_block_size"]) + cache_limit = config["train_loader"]["dataset"]["cache_limit"] + shuffle_algo = None + if "shuffle_algo" in config["train_loader"]["dataset"]: + shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] + shuffle_block_size = config["train_loader"]["dataset"]["shuffle_block_size"] seed = config['seed'] - print(yaml.dump(config, default_flow_style=False)) + # get step timestamps, real throughput, and network use from the run + step_timestamps = run.history(samples=batches_per_epoch, keys=["_timestamp"], pandas=True) + real_batch_throughput = run.history(samples=batches_per_epoch-throughput_window, keys=["throughput/batches_per_sec"], pandas=True) - # get real throughput and network use from the run - real_batch_throughput = run.history(samples=batches_per_epoch-throughput_window, keys=['throughput/batches_per_sec'], pandas=True) - real_network_use = run.history(stream="system", pandas=True)['system.network.recv'] + real_network_use = run.history(stream="system", pandas=True)[["_timestamp", "system.network.recv"]] + + # merge real_network_use with step_timestamps + merged_network_use = pd.merge_asof(real_network_use, step_timestamps, on="_timestamp", direction="nearest") # simulate throughput and network use given the inputs step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, - device_batch_size, avg_batch_time, batches_per_epoch, + device_batch_size, time_per_sample, batches_per_epoch, epochs, physical_nodes, devices, node_network_bandwidth, workers, canonical_nodes, predownload, cache_limit, shuffle_algo, shuffle_block_size, seed) @@ -60,21 +86,37 @@ immediate_batch_throughput = 1 / step_times shard_downloads_cumulative = np.cumsum(shard_downloads) + shard_downloads_steps = np.arange(shard_downloads.shape[0]) + sim_downloads = pd.DataFrame({"_step": shard_downloads_steps, "sim_downloads": shard_downloads_cumulative}) + # merge simulated downloads with real downloads dataframe + merged_network_use = pd.merge_asof(merged_network_use, sim_downloads, on="_step", direction="nearest") - step_times_rolling_avg = np.convolve(step_times, np.ones(throughput_window) / throughput_window, mode='valid') + step_times_rolling_avg = np.convolve(step_times, np.ones(throughput_window) / throughput_window, mode='valid')[:-1] batch_throughput_rolling_avg = 1 / step_times_rolling_avg - batch_throughput_rolling_avg = np.concatenate((np.array([0] * 9), batch_throughput_rolling_avg)) + sim_throughput = pd.DataFrame({"_step": throughput_window + np.arange(batch_throughput_rolling_avg.shape[0]), "sim_throughput": batch_throughput_rolling_avg}) + merged_throughput = pd.merge_asof(real_batch_throughput, sim_throughput, on="_step", direction="nearest") + + # get similarity scores + throughput_similarity = get_similarity_percentage(merged_throughput["throughput/batches_per_sec"].to_numpy(), merged_throughput["sim_throughput"].to_numpy()) + network_similarity = get_similarity_percentage(physical_nodes*(merged_network_use["system.network.recv"].to_numpy()), compression_ratio*(merged_network_use["sim_downloads"].to_numpy())) + + # print params and results to easily paste to spreadsheet + print(run.name, seed, canonical_nodes, physical_nodes, predownload, shuffle_algo, shuffle_block_size, cache_limit, batches_per_epoch, throughput_similarity, network_similarity) fig, (ax1, ax2) = plt.subplots(2, 1) - ax1.set_title("throughput") - #ax1.plot(real_batch_throughput["_step"], real_batch_throughput["throughput/batches_per_sec"], color="red", label="real") - ax1.plot(np.arange(batch_throughput_rolling_avg.shape[0]), batch_throughput_rolling_avg, color="blue", label="sim") + ax1.set_title("throughput - score: " + str(throughput_similarity)) + ax1.plot(merged_throughput["_step"], merged_throughput["throughput/batches_per_sec"], color="red", label="real") + ax1.plot(merged_throughput["_step"], merged_throughput["sim_throughput"], color="blue", label="sim") ax1.legend() - ax2.set_title("network use") - #ax2.plot(list(range(len(real_network_use))), real_network_use, color="red", label="real") - ax2.plot(np.arange(shard_downloads_cumulative.shape[0]), shard_downloads_cumulative, color="blue", label="sim") + ax2.set_title("network use - score: " + str(network_similarity)) + # wandb only logs network use for node 0. multiply by number of nodes to get total network use + ax2.plot(merged_network_use["_timestamp"], physical_nodes*merged_network_use["system.network.recv"], color="red", label="real") + # simulation assumes all shards are downloaded uncompressed (overestimates). multiply by compression ratio to get true network use + ax2.plot(merged_network_use["_timestamp"], compression_ratio*merged_network_use["sim_downloads"], color="blue", label="sim") ax2.legend() + fig.set_figheight(8) + plt.show() \ No newline at end of file diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py new file mode 100644 index 000000000..bac4de564 --- /dev/null +++ b/scripts/simulation/simulation_ui.py @@ -0,0 +1,159 @@ +# simulator ui using streamlit + +import streamlit as st +import numpy as np +import altair as alt +import pandas as pd +from simulation_funcs import simulate +from streaming.base.util import number_abbrev_to_int, bytes_to_int + + +# set up page +st.set_page_config(layout="wide") +col1, space, col2 = st.columns((8, 1, 8)) +col2.title("Streaming Simulator") +col2.write("Enter run parameters in the left panel.") +col2.text("") +progress_bar = col1.progress(0) +status_text = col1.empty() +col1.text("") +throughput_plot = col2.empty() +network_plot = col2.empty() +throughput_window = 10 + +def get_chart(data, throughput=True): + hover = alt.selection_single( + fields=["step"], + nearest=True, + on="mouseover", + empty="none", + ) + + lines = ( + alt.Chart(data, title="Throughput" if throughput else "Network Usage") + .mark_line() + .encode( + x="step", + y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)" + ) + ) + + # Draw points on the line, and highlight based on selection + points = lines.transform_filter(hover).mark_circle(size=65) + + # Draw a rule at the location of the selection + tooltips = ( + alt.Chart(data) + .mark_rule() + .encode( + x="step", + y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)", + opacity=alt.condition(hover, alt.value(0.3), alt.value(0)), + tooltip=[ + alt.Tooltip("step", title="Step"), + alt.Tooltip("throughput (batches/s)" if throughput else "cumulative network usage (bytes)", title="Throughput" if throughput else "Network Usage"), + ], + ) + .add_selection(hover) + ) + return (lines + points + tooltips).interactive() + +def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches_per_epoch, device_batch_size, + workers, canonical_nodes, predownload, shuffle_algo, cache_limit, shuffle_block_size, + seed, physical_nodes, devices, time_per_sample, node_network_bandwidth): + gen_sim = simulate(shards, samples_per_shard, avg_shard_size, + device_batch_size, time_per_sample, batches_per_epoch, + epochs, physical_nodes, devices, node_network_bandwidth, + workers, canonical_nodes, predownload, cache_limit, + shuffle_algo, shuffle_block_size, seed, True) + + gen_step_times = [] + gen_shard_downloads = [] + throughput_data = [] + network_data = [] + throughput_steps = [] + network_steps = [] + new_throughput_data = [] + new_network_data = [] + for i, (step_time, shard_download) in enumerate(gen_sim): + if step_time is not None and shard_download is not None: + gen_step_times.append(step_time) + gen_shard_downloads.append(shard_download) + # plot throughput once we have enough samples for the window + if i >= throughput_window - 1: + step_time_window = np.array(gen_step_times[-throughput_window:]) + throughput = 1/np.mean((step_time_window)) + throughput_steps.append(i+1) + throughput_data.append(throughput) + new_throughput_data.append(throughput) + # plot network usage + cumulative_shard_download = np.sum(np.array(gen_shard_downloads)) + network_steps.append(i+1) + network_data.append(cumulative_shard_download) + new_network_data.append(cumulative_shard_download) + + # update plots and percentages once every 500 batches + if i == 1 or i % 500 == 0 or i == batches_per_epoch * epochs - 1: + throughput_df = pd.DataFrame({"step": throughput_steps, "throughput (batches/s)": throughput_data}) + network_df = pd.DataFrame({"step": network_steps, "cumulative network usage (bytes)": network_data}) + throughput_plot.altair_chart(get_chart(throughput_df, True), use_container_width=True) + network_plot.altair_chart(get_chart(network_df, False), use_container_width=True) + # update progress bar and text + percentage = int(100*(i+1) / (batches_per_epoch * epochs)) + status_text.text("%i%% Complete" % percentage) + progress_bar.progress(percentage) + +with col1.form("my_form"): + + submitted = st.form_submit_button("Simulate Run", use_container_width=True) + st.text("") + + col3, col4 = st.columns(2) + + # dataset + col3.write("**Dataset Parameters**") + shards = col3.number_input('number of shards', step=1, value=20850, help="number of total shards across your whole dataset.") + samples_per_shard = col3.number_input('samples per shard', step=1, value=4093, help="average number of samples contained in each shard. the `index.json` file can help estimate this.") + avg_shard_size = col3.text_input('average shard size (bytes)', value="67MB", help="average size, in bytes, of a single shard. the `index.json` file can help estimate this.") + col3.text("") + + # training + col4.write("**Training Parameters**") + epochs = col4.number_input('number of epochs', step=1, value=1, help="number of epochs for this run.") + batches_per_epoch = col4.text_input('batches per epoch', value="3k", help="number of batches per epoch for this run.") + batches_per_epoch = number_abbrev_to_int(batches_per_epoch) + device_batch_size = col4.number_input('device batch size (samples)', step=1, value=16, help="number of samples per device (GPU) per batch. the global batch size is `device_batch_size * devices_per_node * physical_nodes`") + col4.text("") + + # hardware and network + col3.write("**Hardware and Network Parameters**") + physical_nodes = col3.number_input('number of physical nodes', step=1, value=2, help="number of physical nodes for this run. a node typically consists of 8 devices (GPUs).") + devices = col3.number_input('devices per node', step=1, value=8, help="number of devices (GPUs) per node for this run. there are typically 8 devices per node.") + time_per_sample = col3.number_input('process time per sample (s)', step = 0.0005, value=0.0175, format="%.4f", help="time for one device to process one sample from your dataset.") + node_network_bandwidth = col3.text_input('network bandwidth per node (bytes/s)', value="2GB", help="network bandwidth available to each node. in practice, network bandwidth is variable and is affected by many factors, including cluster demand.") + col3.text("") + + # streaming + col4.write("**Streaming Parameters**") + workers = col4.number_input('workers per device', step=1, value=8, help="number of dataloader workers per device (GPU).") + canonical_nodes = col4.number_input('number of canonical nodes', step=1, value=8, help="number of canonical nodes to split your dataset into. a canonical node is a bucket of shards that is assigned to a particular physical node.") + predownload = col4.text_input('samples to download ahead per worker', value=64, help="number of samples ahead each worker should download. predownload does not occur before the first batch; rather, it occurs while training is ongoing.") + #shuffle_algo = col4.text_input('shuffling algorithm', value="py1b", help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") + shuffle_algo = col4.selectbox('shuffling algorithm', ["py1b", "py1br", "py1e", "py1s", "py2s", "naive", "None"], help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") + if shuffle_algo == "None": + shuffle_algo = None + cache_limit = col4.text_input('cache limit (bytes)', value="None", help="cache limit per node for this run. setting cache limit too low will impact throughput.") + if cache_limit == "None": + cache_limit = None + else: + cache_limit = bytes_to_int(cache_limit) + shuffle_block_size = col4.text_input('shuffle block size (samples)', value="16M", help="shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly shuffled inside each bucket of shards (aka canonical node).") + seed = col4.number_input('random seed', step=1, value=42, help="random seed for shuffling.") + col4.text("") + + if submitted: + submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches_per_epoch, device_batch_size, + workers, canonical_nodes, predownload, shuffle_algo, cache_limit, shuffle_block_size, + seed, physical_nodes, devices, time_per_sample, node_network_bandwidth) + + \ No newline at end of file diff --git a/scripts/simulation/simulation_web.py b/scripts/simulation/simulation_web.py deleted file mode 100644 index 1dc2f551b..000000000 --- a/scripts/simulation/simulation_web.py +++ /dev/null @@ -1,343 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Web app to simulate streaming with different input params. - -Install: - - pip3 install fastapi pydantic uvicorn - -Run: - - uvicorn scripts.simulation:simulation_web:app --port 2000 --reload -""" - -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -import base64 -from typing import Optional - -from fastapi import FastAPI -from fastapi.responses import HTMLResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from simulation.simulation_funcs import plot_simulation, simulate - -INDEX = ''' - - - - Streaming Simulator - - - - - - - - -
-
- - - - - - - - - - - - - -
# shards
# samples per shard
average shard size (bytes)
-
-
- - - - - - - - - - - - - - - - - -
# epochs
batches per epoch
device batch size
average batch time (seconds)
-
-
- - - - - - - - - - - - - -
# physical nodes
# devices per node
internet bandwidth per node (bytes/sec)
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# canonical nodes
# workers per device
predownload per worker
cache limit per node
shuffle algorithm
shuffle block size
shuffle seed
-
-
-
-
Simulate Streaming
-
-
-
-
- - - -''' - -from pathlib import Path - -current_file = Path(__file__) -current_file_dir = current_file.parent -project_root = current_file_dir.parent -project_root_absolute = project_root.resolve() -static_root_absolute = project_root_absolute / 'simulation/static' - -app = FastAPI() - -# mount static file directory for the nice loading gif :) -app.mount('/static', StaticFiles(directory=static_root_absolute), name='static') - - -@app.get('/') -def get_root() -> HTMLResponse: - """Get the index HTML file.""" - return HTMLResponse(INDEX) - - -class GetSimulationRequest(BaseModel): - """simulation input parameters.""" - shards: int - samples_per_shard: int - avg_shard_size: float - device_batch_size: int - avg_batch_time: float - batches_per_epoch: int - epochs: int - physical_nodes: int - devices: int - node_network_bandwidth: float - workers: int - canonical_nodes: int - predownload: int - cache_limit: Optional[int] = None - shuffle_algo: Optional[str] = None - shuffle_block_size: int = 1 << 18 - seed: int = 42 - - -@app.post('/api/simulate') -def post_api_simulate(req: GetSimulationRequest) -> dict: - """Serve a POST request to simulate a run. - - Args: - req (GetSimulationRequest): The simulation input params. - - Returns: - dict: JSON object containing the base64 image string for the simulation plots. - """ - step_times, shard_downloads = simulate(req.shards, req.samples_per_shard, req.avg_shard_size, - req.device_batch_size, req.avg_batch_time, - req.batches_per_epoch, req.epochs, req.physical_nodes, - req.devices, req.node_network_bandwidth, req.workers, - req.canonical_nodes, req.predownload, req.cache_limit, - req.shuffle_algo, req.shuffle_block_size, req.seed) - - plots_buffer = plot_simulation(step_times, shard_downloads) - - if plots_buffer is not None: - base64_encoded_image = base64.b64encode(plots_buffer).decode('utf-8') - return {'image': base64_encoded_image} - else: - raise ValueError('plot_simulation returned None. Set web=True to return bytes.') diff --git a/scripts/simulation/static/loading.gif b/scripts/simulation/static/loading.gif deleted file mode 100644 index b789b68c08554ce0d93a569d2608b0d0276cb674..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76341 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
&Pq{hKY+2bRu!*o!vPn1D2KGhB|Fd^ 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 Hge?9aBQ={) diff --git a/setup.py b/setup.py index 4b5f929c6..db4f28d6c 100644 --- a/setup.py +++ b/setup.py @@ -79,6 +79,8 @@ 'uvicorn==0.23.2', 'pytest-split==0.8.1', 'sortedcollections==2.1.0', + 'streamlit==1.26.0', + 'altair==5.0.1', ] extra_deps['docs'] = [ diff --git a/streaming/base/shuffle/__init__.py b/streaming/base/shuffle/__init__.py index fe289eac9..2eb662a6a 100644 --- a/streaming/base/shuffle/__init__.py +++ b/streaming/base/shuffle/__init__.py @@ -11,6 +11,7 @@ from streaming.base.shuffle.py1s import get_shuffle_py1s from streaming.base.shuffle.py2s import get_shuffle_py2s from streaming.base.shuffle.py1e import get_shuffle_py1e +from streaming.base.shuffle.py1br import get_shuffle_py1br algos = { 'py1b': get_shuffle_py1b, @@ -18,6 +19,7 @@ 'py2s': get_shuffle_py2s, 'naive': get_shuffle_naive, 'py1e': get_shuffle_py1e, + 'py1br': get_shuffle_py1br, } diff --git a/streaming/base/shuffle/py1br.py b/streaming/base/shuffle/py1br.py new file mode 100644 index 000000000..f407decd4 --- /dev/null +++ b/streaming/base/shuffle/py1br.py @@ -0,0 +1,91 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Shuffling algorithm that shuffles in fixed-size blocks. + +These units are presumably larger or much larger than single shards, leading to better shuffledness +at the cost of having to download more shards to make progress. +""" + +import numpy as np +from numpy.typing import NDArray + +from streaming.base.shuffle.py1s import divide_spans + + +def get_shuffle_py1br(shard_sizes: NDArray[np.int64], + num_canonical_nodes: int, + seed: int, + epoch: int, + block_size: int = 1 << 18) -> NDArray[np.int64]: + """Get the shuffled global ordering of samples for an epoch. + + The assignment of shards to nodes is fixed across epochs, but each grouping of shards is + processed concurrently in a different order by each node's workers each epoch. + Args: + shard_sizes (NDArray[np.int64]): Number of samples contained in each shard, in order. + num_canonical_nodes (int): Number of canonical nodes. + seed (int): Base random seed, which is held constant over an entire training run. + epoch (int): Current epoch, which is added to the seed to get a different deterministic + shuffle each epoch. + block_size (int): Unit of shuffle. For py1br shuffling method, the block size is chosen + uniformly at random in the range (0.75*block_size, 1.25*block_size). Defaults to ``1 << 18``. + + Returns: + NDArray[np.int64]: 1:1 mapping of sample ID to shuffled sample ID. + """ + # Create each shard's sample ID span (start, stop excl). + spans = [] + num_samples = 0 + for shard_size in shard_sizes: + span = num_samples, num_samples + shard_size + spans.append(span) + num_samples += shard_size + + # Generate the initial ordering of shards, which is fixed over an entire training run. + run_rng = np.random.default_rng(seed) + run_rng.shuffle(spans) + + # Break the shard spans at canonical node boundaries. + spans, node_spans = divide_spans(spans, num_samples, num_canonical_nodes) + + # Shuffle the span ordering within each canonical node uniquely to this epoch. + epoch_rng = np.random.default_rng(seed + epoch) + for node_start_span, node_stop_span in node_spans: + node_span = spans[node_start_span:node_stop_span] + epoch_rng.shuffle(node_span) # pyright: ignore + spans[node_start_span:node_stop_span] = node_span + + # Populate the global sample ID mapping, shuffling within each block within each node. + ids = np.empty(num_samples, np.int64) + node_stop_sample = 0 + stagger = epoch_rng.integers(0, int(0.75 * block_size), (num_canonical_nodes,)) + for node, (node_start_span, node_stop_span) in enumerate(node_spans): + node_start_sample = node_stop_sample + + # Populate sample IDs given the span ordering for this node. + for span_start_sample, span_stop_sample in spans[node_start_span:node_stop_span]: + span_size = span_stop_sample - span_start_sample + ids[node_stop_sample:node_stop_sample + span_size] = \ + np.arange(span_start_sample, span_stop_sample) + node_stop_sample += span_size + + # Get randomized and staggered block ranges for the current node. + block_staggered_ranges = [] + blocks_end = node_start_sample + node_stagger = stagger[node] + while blocks_end < node_stop_sample: + rand_block_size = epoch_rng.integers(int(0.75 * block_size), int(1.25 * block_size)) + # don't want the block to start before the first sample of the node + staggered_block_start = max(blocks_end - node_stagger, node_start_sample) + # don't want the block to stop after the last sample of the node + staggered_block_stop = min(blocks_end + rand_block_size - node_stagger, + node_stop_sample) + block_staggered_ranges.append((staggered_block_start, staggered_block_stop)) + blocks_end += staggered_block_stop - staggered_block_start + + # Shuffle within each staggered, randomized block. + for block_start, block_stop in block_staggered_ranges: + epoch_rng.shuffle(ids[block_start:block_stop]) + + return ids \ No newline at end of file From 2a3ecde9cbf3877f38e1f29d52faee6f7528fe46 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Mon, 28 Aug 2023 00:22:48 -0700 Subject: [PATCH 09/31] testing fixes, remove prints --- scripts/simulation/simulation_funcs.py | 6 ------ scripts/simulation/simulation_testing.py | 4 +++- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index 5c112acce..c4900ea37 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -135,9 +135,6 @@ def simulate(shards: int, for epoch in range(epochs): - print("shards in node 0:", len(node_shards[0])) - print("shards in node 1:", len(node_shards[1])) - if shuffle_algo is not None: # get shuffle of sample ids shuffle = get_shuffle(algo=shuffle_algo, @@ -153,9 +150,6 @@ def simulate(shards: int, # reshape shuffled_partition to get samples, in order, per worker samples_per_worker = partitions.reshape(physical_nodes, devices, workers, -1) - print("shards needed for node 0:", set(sample_to_shard[partitions[0]].flatten())) - print("shards needed for node 1:", set(sample_to_shard[partitions[1]].flatten())) - worker_sample_index = 0 # track which sample we are on. is an index per worker. worker_download_indices = np.array( [0] * physical_nodes diff --git a/scripts/simulation/simulation_testing.py b/scripts/simulation/simulation_testing.py index c47df2fe0..a0060d502 100644 --- a/scripts/simulation/simulation_testing.py +++ b/scripts/simulation/simulation_testing.py @@ -77,12 +77,14 @@ def get_similarity_percentage(real, sim): # simulate throughput and network use given the inputs - step_times, shard_downloads = simulate(shards, samples_per_shard, avg_shard_size, + result = simulate(shards, samples_per_shard, avg_shard_size, device_batch_size, time_per_sample, batches_per_epoch, epochs, physical_nodes, devices, node_network_bandwidth, workers, canonical_nodes, predownload, cache_limit, shuffle_algo, shuffle_block_size, seed) + step_times, shard_downloads = next(result) + immediate_batch_throughput = 1 / step_times shard_downloads_cumulative = np.cumsum(shard_downloads) From 2cd70f400d14d740898f783e0f9ac7a180cf6351 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Mon, 28 Aug 2023 00:30:14 -0700 Subject: [PATCH 10/31] ui text --- scripts/simulation/simulation_ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py index bac4de564..03946735b 100644 --- a/scripts/simulation/simulation_ui.py +++ b/scripts/simulation/simulation_ui.py @@ -122,7 +122,7 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches epochs = col4.number_input('number of epochs', step=1, value=1, help="number of epochs for this run.") batches_per_epoch = col4.text_input('batches per epoch', value="3k", help="number of batches per epoch for this run.") batches_per_epoch = number_abbrev_to_int(batches_per_epoch) - device_batch_size = col4.number_input('device batch size (samples)', step=1, value=16, help="number of samples per device (GPU) per batch. the global batch size is `device_batch_size * devices_per_node * physical_nodes`") + device_batch_size = col4.number_input('device batch size', step=1, value=16, help="number of samples per device (GPU) per batch. the global batch size is `device_batch_size * devices_per_node * physical_nodes`") col4.text("") # hardware and network @@ -137,7 +137,7 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches col4.write("**Streaming Parameters**") workers = col4.number_input('workers per device', step=1, value=8, help="number of dataloader workers per device (GPU).") canonical_nodes = col4.number_input('number of canonical nodes', step=1, value=8, help="number of canonical nodes to split your dataset into. a canonical node is a bucket of shards that is assigned to a particular physical node.") - predownload = col4.text_input('samples to download ahead per worker', value=64, help="number of samples ahead each worker should download. predownload does not occur before the first batch; rather, it occurs while training is ongoing.") + predownload = col4.text_input('predownload per worker (samples)', value=64, help="number of samples ahead each worker should download. predownload does not occur before the first batch; rather, it occurs while training is ongoing.") #shuffle_algo = col4.text_input('shuffling algorithm', value="py1b", help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") shuffle_algo = col4.selectbox('shuffling algorithm', ["py1b", "py1br", "py1e", "py1s", "py2s", "naive", "None"], help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") if shuffle_algo == "None": From 2a8ba75ae03b292540410aad5b9f00fd4ddb9499 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Mon, 28 Aug 2023 11:16:01 -0700 Subject: [PATCH 11/31] ui text change --- scripts/simulation/simulation_ui.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py index 03946735b..8e70ca0ff 100644 --- a/scripts/simulation/simulation_ui.py +++ b/scripts/simulation/simulation_ui.py @@ -138,7 +138,6 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches workers = col4.number_input('workers per device', step=1, value=8, help="number of dataloader workers per device (GPU).") canonical_nodes = col4.number_input('number of canonical nodes', step=1, value=8, help="number of canonical nodes to split your dataset into. a canonical node is a bucket of shards that is assigned to a particular physical node.") predownload = col4.text_input('predownload per worker (samples)', value=64, help="number of samples ahead each worker should download. predownload does not occur before the first batch; rather, it occurs while training is ongoing.") - #shuffle_algo = col4.text_input('shuffling algorithm', value="py1b", help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") shuffle_algo = col4.selectbox('shuffling algorithm', ["py1b", "py1br", "py1e", "py1s", "py2s", "naive", "None"], help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") if shuffle_algo == "None": shuffle_algo = None From 9037ddc3877584af45d7e1ac74a0f8e12fefee55 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 29 Aug 2023 16:30:52 -0700 Subject: [PATCH 12/31] added streaming metrics, warnings, errors. --- scripts/simulation/simulation_funcs.py | 72 ++++++++++++++++++++++-- scripts/simulation/simulation_ui.py | 77 ++++++++++++++++++-------- 2 files changed, 121 insertions(+), 28 deletions(-) diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index c4900ea37..5e6b981bf 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -15,6 +15,7 @@ from numpy.typing import NDArray from simulation.last_used_ordered_set import LastUsedOrderedSet from sortedcollections import OrderedSet +import time from streaming.base.partition import get_partitions from streaming.base.shuffle import get_shuffle @@ -38,7 +39,7 @@ def simulate(shards: int, shuffle_algo: Optional[str] = None, shuffle_block_size: Union[int, str] = 1 << 18, seed: int = 42, - generator: bool = False) -> Union[Tuple[int, int], Tuple[NDArray, NDArray]]: + generator: bool = False) -> Union[Tuple[int, int], Tuple[NDArray, NDArray], np.float64]: """Simulates step time and downloads using streaming for the specified input parameters. Key Notes and Assumptions: @@ -82,6 +83,10 @@ def simulate(shards: int, """ # simulation preparation... + # tracking startup time + start_time = time.time() + startup_time = 0 + # make sure potential string args are usable samples_per_shard = number_abbrev_to_int(samples_per_shard) avg_shard_size = bytes_to_int(avg_shard_size) @@ -175,6 +180,10 @@ def simulate(shards: int, worker_downloads.append(download_shards) node_worker_downloads.append(worker_downloads) + # if first epoch, add time so far to startup time + if epoch == 0: + startup_time += time.time() - start_time + for batch_num in range(batches_per_epoch): if (batch_num + 1) % notification_batches == 0: @@ -309,6 +318,11 @@ def simulate(shards: int, # over all nodes. And that means the download_time_left for nodes that finish earlier will be longer # and only the slowest node will have a download_time_left of avg_batch_time. slowest_download_time = np.max(node_batch_download_times) + + # if we are on the first step, add slowest_download_time to startup time + if epoch == 0 and batch_num == 0: + startup_time += slowest_download_time + for physical_node in range(physical_nodes): # we will always have the avg_batch_time to do more downloads, plus whatever amount of time this node finished early @@ -391,7 +405,16 @@ def simulate(shards: int, if not generator: step_times = np.array(step_times) shard_downloads = np.array(shard_downloads) - yield step_times, shard_downloads + yield step_times, shard_downloads, startup_time + else: + yield startup_time + +def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray: + step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + batch_throughput_rolling_avg = np.concatenate((np.array([0] * (window-1)), batch_throughput_rolling_avg)) + + return batch_throughput_rolling_avg def plot_simulation(step_times: NDArray, @@ -418,10 +441,7 @@ def plot_simulation(step_times: NDArray, shard_downloads_cumulative = np.cumsum(shard_downloads) - step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') - batch_throughput_rolling_avg = 1 / step_times_rolling_avg - batch_throughput_rolling_avg = np.concatenate( - (np.array([0] * 9), batch_throughput_rolling_avg)) + batch_throughput_rolling_avg = get_rolling_avg_throughput(step_times, window) # matplotlib plot with 2 vertically stacked subplots fig, (ax1, ax2) = plt.subplots(2, 1) @@ -461,3 +481,43 @@ def plot_simulation(step_times: NDArray, else: plt.show() return None + + +def get_simulation_stats(step_times, shard_downloads, time_per_sample, device_batch_size): + """Gets simulation stats for web UI. + + Args: + step_times (NDArray): time per step, as calculated by simulation + shard_downloads (NDArray): download size (bytes) per step, as calculated by simulation + + Returns: + Tuple[float, float, float]: percent of download-limited steps, warmup time + """ + + # calculate percent of download-limited steps + min_step_time = time_per_sample * device_batch_size + all_throughput_drops = np.count_nonzero(step_times > (min_step_time)) + + # calculate warmup time (time to first max possible rolling average throughput) + max_throughput = 1 / min_step_time + rolling_avg_throughput = get_rolling_avg_throughput(step_times) + if np.max(rolling_avg_throughput) == max_throughput: + warmup_step = np.argmax(rolling_avg_throughput >= (max_throughput)) + 1 + warmup_time = np.sum(step_times[:warmup_step]) + else: + # we never hit the max possible throughput + warmup_step = rolling_avg_throughput.shape[0] + warmup_time = np.sum(step_times) + + # see if there are throughput drops after warmup so we can notify users + if warmup_step != rolling_avg_throughput.shape[0]: + # if we did hit the max throughput then we check for later drops + post_warmup_throughput_drops = np.count_nonzero(step_times[warmup_step:] > min_step_time) + else: + # since warmup was the whole time, there are no post-warmup throughput drops + post_warmup_throughput_drops = 0 + + return all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops + + + diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py index 8e70ca0ff..df4a18f7d 100644 --- a/scripts/simulation/simulation_ui.py +++ b/scripts/simulation/simulation_ui.py @@ -4,7 +4,7 @@ import numpy as np import altair as alt import pandas as pd -from simulation_funcs import simulate +from simulation_funcs import simulate, get_simulation_stats from streaming.base.util import number_abbrev_to_int, bytes_to_int @@ -22,19 +22,27 @@ throughput_window = 10 def get_chart(data, throughput=True): - hover = alt.selection_single( + hover = alt.selection_point( fields=["step"], nearest=True, on="mouseover", - empty="none", + empty=False, ) lines = ( - alt.Chart(data, title="Throughput" if throughput else "Network Usage") + alt.Chart(data, title="Throughput (per step and " + str(throughput_window) + "-step rolling average)") .mark_line() .encode( x="step", - y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)" + y="throughput (batches/s)", + color="measurement" + ) + ) if throughput else ( + alt.Chart(data, title="Cumulative Network Usage") + .mark_line() + .encode( + x="step", + y="cumulative network usage (bytes)" ) ) @@ -54,7 +62,7 @@ def get_chart(data, throughput=True): alt.Tooltip("throughput (batches/s)" if throughput else "cumulative network usage (bytes)", title="Throughput" if throughput else "Network Usage"), ], ) - .add_selection(hover) + .add_params(hover) ) return (lines + points + tooltips).interactive() @@ -69,33 +77,39 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches gen_step_times = [] gen_shard_downloads = [] - throughput_data = [] + rolling_throughput_data = [] + immediate_throughput_data = [] network_data = [] - throughput_steps = [] - network_steps = [] - new_throughput_data = [] - new_network_data = [] - for i, (step_time, shard_download) in enumerate(gen_sim): - if step_time is not None and shard_download is not None: + steps = [] + time_to_first_batch = 0 + for i, result in enumerate(gen_sim): + # if result length is 2, then we have (step_time, shard_download). otherwise is just returning startup time. + if type(result) != np.float64: + step_time, shard_download = result gen_step_times.append(step_time) gen_shard_downloads.append(shard_download) # plot throughput once we have enough samples for the window if i >= throughput_window - 1: step_time_window = np.array(gen_step_times[-throughput_window:]) throughput = 1/np.mean((step_time_window)) - throughput_steps.append(i+1) - throughput_data.append(throughput) - new_throughput_data.append(throughput) + rolling_throughput_data.append(throughput) + else: + rolling_throughput_data.append(0) + immediate_throughput_data.append(1/step_time) # plot network usage cumulative_shard_download = np.sum(np.array(gen_shard_downloads)) - network_steps.append(i+1) network_data.append(cumulative_shard_download) - new_network_data.append(cumulative_shard_download) + steps.append(i+1) + else: + time_to_first_batch = result - # update plots and percentages once every 500 batches - if i == 1 or i % 500 == 0 or i == batches_per_epoch * epochs - 1: - throughput_df = pd.DataFrame({"step": throughput_steps, "throughput (batches/s)": throughput_data}) - network_df = pd.DataFrame({"step": network_steps, "cumulative network usage (bytes)": network_data}) + # update plots and percentages at regular intervals + interval = (batches_per_epoch*epochs) // 10 + if i == 1 or i % interval == 0 or i == batches_per_epoch * epochs - 1: + rolling_throughput_df = pd.DataFrame({"step": steps, "measurement": [" rolling avg"]*len(rolling_throughput_data), "throughput (batches/s)": rolling_throughput_data}) + immediate_throughput_df = pd.DataFrame({"step": steps, "measurement": ["per step"]*len(immediate_throughput_data), "throughput (batches/s)": immediate_throughput_data}) + throughput_df = pd.concat([immediate_throughput_df, rolling_throughput_df]) + network_df = pd.DataFrame({"step": steps, "cumulative network usage (bytes)": network_data}) throughput_plot.altair_chart(get_chart(throughput_df, True), use_container_width=True) network_plot.altair_chart(get_chart(network_df, False), use_container_width=True) # update progress bar and text @@ -103,6 +117,25 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches status_text.text("%i%% Complete" % percentage) progress_bar.progress(percentage) + gen_step_times = np.array(gen_step_times) + gen_shard_downloads = np.array(gen_shard_downloads) + + all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = get_simulation_stats(gen_step_times, gen_shard_downloads, time_per_sample, device_batch_size) + + if warmup_step == batches_per_epoch*epochs: + # display error if the warmup phase is the whole run, meaning that we never hit peak throughput. + col2.error('This configuration is severely bottlenecked by downloading. The run will not be performant.', icon="🚨") + elif post_warmup_throughput_drops: + # display warning if post-warmup throughput drops are more than 10% of the run. + col2.warning('This configuration experiences some downloading-related slowdowns even after warmup.', icon="⚠️") + col2.write("**{0} steps**, or **{1:.1f}%** of all steps, waited for shard downloads.".format(all_throughput_drops, 100*all_throughput_drops/(batches_per_epoch*epochs))) + if warmup_step != batches_per_epoch*epochs: + # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) + col2.write("There were **{} steps** that waited for shard downloads after the warmup period.".format(post_warmup_throughput_drops)) + col2.write("Estimated time to first batch: **{0:.2f} s**".format(time_to_first_batch)) + col2.write("Estimated warmup time: **{0:.2f} s**".format(warmup_time)) + + with col1.form("my_form"): submitted = st.form_submit_button("Simulate Run", use_container_width=True) From a4902e6e71cb8e05be5e7932f31ce0d6f02bc40c Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 6 Sep 2023 13:15:03 -0700 Subject: [PATCH 13/31] modified update interval --- scripts/simulation/simulation_ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py index df4a18f7d..ed35c1c33 100644 --- a/scripts/simulation/simulation_ui.py +++ b/scripts/simulation/simulation_ui.py @@ -104,8 +104,8 @@ def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches time_to_first_batch = result # update plots and percentages at regular intervals - interval = (batches_per_epoch*epochs) // 10 - if i == 1 or i % interval == 0 or i == batches_per_epoch * epochs - 1: + plot_interval = (batches_per_epoch*epochs) // 15 + if i == 1 or i % plot_interval == 0 or i == batches_per_epoch * epochs - 1: rolling_throughput_df = pd.DataFrame({"step": steps, "measurement": [" rolling avg"]*len(rolling_throughput_data), "throughput (batches/s)": rolling_throughput_data}) immediate_throughput_df = pd.DataFrame({"step": steps, "measurement": ["per step"]*len(immediate_throughput_data), "throughput (batches/s)": immediate_throughput_data}) throughput_df = pd.concat([immediate_throughput_df, rolling_throughput_df]) From a42bc241269cdf3271e69a3ac916c90d57bd6b5d Mon Sep 17 00:00:00 2001 From: Saaketh Date: Mon, 18 Sep 2023 10:53:28 -0700 Subject: [PATCH 14/31] sim changes --- scripts/simulation/simulation_funcs.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py index 5e6b981bf..724d8bef7 100644 --- a/scripts/simulation/simulation_funcs.py +++ b/scripts/simulation/simulation_funcs.py @@ -101,6 +101,8 @@ def simulate(shards: int, # or multiple streams for now. shard_sizes = np.array([samples_per_shard] * shards) + print("before partition") + # get partition of sample ids # structured as (physical nodes, ranks per node, workers per rank, batches per worker, batch size) orig_partitions = get_partitions(algo='orig', @@ -112,6 +114,8 @@ def simulate(shards: int, batch_size=device_batch_size, drop_first=0) + print("partition done") + # time for the global batch is just device batch size * time per sample, since all devices process their microbatch in parallel avg_batch_time = device_batch_size * time_per_sample @@ -140,6 +144,8 @@ def simulate(shards: int, for epoch in range(epochs): + print("within loop...") + if shuffle_algo is not None: # get shuffle of sample ids shuffle = get_shuffle(algo=shuffle_algo, @@ -149,12 +155,17 @@ def simulate(shards: int, epoch=epoch, block_size=shuffle_block_size) # index into the shuffle to get the new sample at each index + print("shuffle done") partitions = np.where(orig_partitions != -1, shuffle[orig_partitions], -1) + print("after shuffle...") + # handle initial predownload # reshape shuffled_partition to get samples, in order, per worker samples_per_worker = partitions.reshape(physical_nodes, devices, workers, -1) + print("after reshape...") + worker_sample_index = 0 # track which sample we are on. is an index per worker. worker_download_indices = np.array( [0] * physical_nodes From 66688f2bb73d00d9138ffe57f9796f2b41868fe6 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 3 Oct 2023 15:01:41 -0700 Subject: [PATCH 15/31] ported files to streaming repo --- Makefile | 4 +- scripts/simulation/simulation_funcs.py | 534 ------------------ scripts/simulation/simulation_script.py | 52 -- scripts/simulation/simulation_testing.py | 124 ---- scripts/simulation/simulation_ui.py | 191 ------- setup.py | 11 +- simulator/README.md | 50 ++ simulator/core/create_index.py | 81 +++ .../core}/last_used_ordered_set.py | 2 +- simulator/core/main.py | 243 ++++++++ simulator/core/node_tracker.py | 209 +++++++ simulator/core/shard_downloads.py | 133 +++++ simulator/core/shuffle_quality.py | 201 +++++++ simulator/core/sim_time.py | 359 ++++++++++++ simulator/core/simulation_dataset.py | 520 +++++++++++++++++ simulator/core/simulation_spanner.py | 66 +++ simulator/core/simulation_world.py | 56 ++ simulator/core/utils.py | 154 +++++ simulator/core/yaml_processing.py | 171 ++++++ simulator/imgs/downloads.png | Bin 0 -> 97500 bytes simulator/imgs/inputs.png | Bin 0 -> 336523 bytes simulator/imgs/shuffle_quality_graph.png | Bin 0 -> 58333 bytes simulator/imgs/shuffle_quality_toggle.png | Bin 0 -> 13817 bytes simulator/imgs/stats.png | Bin 0 -> 75561 bytes simulator/imgs/throughput.png | Bin 0 -> 83305 bytes simulator/imgs/yaml_toggle.png | Bin 0 -> 10419 bytes simulator/requirements.txt | 10 + simulator/simulation/interface_utils.py | 106 ++++ simulator/simulation/simcli.py | 98 ++++ simulator/simulation/simulation_script.py | 111 ++++ simulator/simulation/simulation_testing.py | 168 ++++++ simulator/simulation/simulation_ui.py | 339 +++++++++++ simulator/simulation/widgets.py | 343 +++++++++++ 33 files changed, 3429 insertions(+), 907 deletions(-) delete mode 100644 scripts/simulation/simulation_funcs.py delete mode 100644 scripts/simulation/simulation_script.py delete mode 100644 scripts/simulation/simulation_testing.py delete mode 100644 scripts/simulation/simulation_ui.py create mode 100644 simulator/README.md create mode 100644 simulator/core/create_index.py rename {scripts/simulation => simulator/core}/last_used_ordered_set.py (96%) create mode 100644 simulator/core/main.py create mode 100644 simulator/core/node_tracker.py create mode 100644 simulator/core/shard_downloads.py create mode 100644 simulator/core/shuffle_quality.py create mode 100644 simulator/core/sim_time.py create mode 100644 simulator/core/simulation_dataset.py create mode 100644 simulator/core/simulation_spanner.py create mode 100644 simulator/core/simulation_world.py create mode 100644 simulator/core/utils.py create mode 100644 simulator/core/yaml_processing.py create mode 100644 simulator/imgs/downloads.png create mode 100644 simulator/imgs/inputs.png create mode 100644 simulator/imgs/shuffle_quality_graph.png create mode 100644 simulator/imgs/shuffle_quality_toggle.png create mode 100644 simulator/imgs/stats.png create mode 100644 simulator/imgs/throughput.png create mode 100644 simulator/imgs/yaml_toggle.png create mode 100644 simulator/requirements.txt create mode 100644 simulator/simulation/interface_utils.py create mode 100644 simulator/simulation/simcli.py create mode 100644 simulator/simulation/simulation_script.py create mode 100644 simulator/simulation/simulation_testing.py create mode 100644 simulator/simulation/simulation_ui.py create mode 100644 simulator/simulation/widgets.py diff --git a/Makefile b/Makefile index e050c9d25..278ba6e66 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ test: web: uvicorn scripts.partition.web:app --port 1337 --reload -simulation: - streamlit run scripts/simulation/simulation_ui.py +simulator: + streamlit run simulator/simulation/simulation_ui.py .PHONY: test lint style diff --git a/scripts/simulation/simulation_funcs.py b/scripts/simulation/simulation_funcs.py deleted file mode 100644 index 724d8bef7..000000000 --- a/scripts/simulation/simulation_funcs.py +++ /dev/null @@ -1,534 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Functions for simulating streaming and displaying results.""" - -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -from io import BytesIO -from typing import Optional, Tuple, Union - -import numpy as np -from numpy.typing import NDArray -from simulation.last_used_ordered_set import LastUsedOrderedSet -from sortedcollections import OrderedSet -import time - -from streaming.base.partition import get_partitions -from streaming.base.shuffle import get_shuffle -from streaming.base.util import bytes_to_int, number_abbrev_to_int - - -def simulate(shards: int, - samples_per_shard: Union[int, str], - avg_shard_size: Union[float, str], - device_batch_size: int, - time_per_sample: float, - batches_per_epoch: Union[int, str], - epochs: int, - physical_nodes: int, - devices: int, - node_network_bandwidth: Union[float,str], - workers: int, - canonical_nodes: int, - predownload: Union[int, str], - cache_limit: Union[int, str, None] = None, - shuffle_algo: Optional[str] = None, - shuffle_block_size: Union[int, str] = 1 << 18, - seed: int = 42, - generator: bool = False) -> Union[Tuple[int, int], Tuple[NDArray, NDArray], np.float64]: - """Simulates step time and downloads using streaming for the specified input parameters. - - Key Notes and Assumptions: - - * assume that batch time is solely made up of two things: batch processing time and batch shard download wait time - * loop through workers round-robin style for batches and for downloads - * assume each node has a separate network bandwidth - * the batch has to wait until all nodes have downloaded the shards containing batch samples. - * for shard eviction itself, use LRU shard eviction to take out the least recently used shard, per node. - * if a shard is unavailable, we use the normal behavior and wait for the regular downloading process to get it. - * if a shard is available in a node, we just use it. - * each node maintains an ordered set of shards that are present in the node - * least recently used shard is the one at the front. most recently used is at the end of the ordered set. - * when a shard is accessed during the batch it is moved to the end of the ordered set - * when a shard is merely downloaded but not accessed for training, it goes to the front of the ordered set - * if cache_limit is set, we check for going above cache limit at every download -- all downloads are assumed same size - - Args: - shards (int): number of shards - samples_per_shard (Union[int, str]): number of samples per shard - avg_shard_size (Union[float, str]): average shard size (bytes) - device_batch_size (int): device batch size (samples) - time_per_sample (float): time to process one sample on one device (seconds) - batches_per_epoch (Union[int, str]): number of batches per epoch - epochs (int): number of epochs - physical_nodes (int): number of physical nodes - devices (int): number of devices per node - node_network_bandwidth (Union[float, str]): network bandwidth per node (bytes/s) - workers (int): number of workers per device - canonical_nodes (int): number of canonical nodes - predownload (Union[int, str]): number of samples to predownload per worker (samples) - cache_limit (Union[int, str, None]): cache limit per node (bytes). Defaults to ``None``. - shuffle_algo (str, optional): shuffling algorithm. Defaults to ``None``. - shuffle_block_size (Union[int, str]): shuffling block size (samples). Defaults to ``1 << 18``. - seed (int): shuffling seed. Defaults to ``42``. - generator (bool): True if we yield throughput and shard_download one step at a time. - - Returns: - step_times (NDArray): time taken by each step, calculated by simulation. - shard_downloads (NDArray): amount of downloaded bytes at each step, calculated by simulation. - """ - # simulation preparation... - - # tracking startup time - start_time = time.time() - startup_time = 0 - - # make sure potential string args are usable - samples_per_shard = number_abbrev_to_int(samples_per_shard) - avg_shard_size = bytes_to_int(avg_shard_size) - batches_per_epoch = number_abbrev_to_int(batches_per_epoch) - node_network_bandwidth = bytes_to_int(node_network_bandwidth) - predownload = number_abbrev_to_int(predownload) - shuffle_block_size = number_abbrev_to_int(shuffle_block_size) - if cache_limit: - cache_limit = bytes_to_int(cache_limit) - - # we assume that each shard is going to be seen only once. Not handling up/down-sampling - # or multiple streams for now. - shard_sizes = np.array([samples_per_shard] * shards) - - print("before partition") - - # get partition of sample ids - # structured as (physical nodes, ranks per node, workers per rank, batches per worker, batch size) - orig_partitions = get_partitions(algo='orig', - num_samples=shards * samples_per_shard, - num_canonical_nodes=canonical_nodes, - num_physical_nodes=physical_nodes, - ranks_per_node=devices, - workers_per_rank=workers, - batch_size=device_batch_size, - drop_first=0) - - print("partition done") - - # time for the global batch is just device batch size * time per sample, since all devices process their microbatch in parallel - avg_batch_time = device_batch_size * time_per_sample - - # simulate training! - - # loop over epochs, then batches... - - notification_batches = int(batches_per_epoch) / 20 - - # track the shards which are present and evicted at each physical node - node_shards = [] - node_evictions = [] - for _ in range(physical_nodes): - node_shards.append(LastUsedOrderedSet()) - node_evictions.append(set()) - - # node cache useages are initially nothin' - node_cache_usage = np.array([0] * physical_nodes) - - # construct mapping of sample index -> shard number - sample_to_shard = np.repeat(np.arange(shards), samples_per_shard) - - # track stats for each step - step_times = [] - shard_downloads = [] - - for epoch in range(epochs): - - print("within loop...") - - if shuffle_algo is not None: - # get shuffle of sample ids - shuffle = get_shuffle(algo=shuffle_algo, - shard_sizes=shard_sizes, - num_canonical_nodes=canonical_nodes, - seed=seed, - epoch=epoch, - block_size=shuffle_block_size) - # index into the shuffle to get the new sample at each index - print("shuffle done") - partitions = np.where(orig_partitions != -1, shuffle[orig_partitions], -1) - - print("after shuffle...") - - # handle initial predownload - # reshape shuffled_partition to get samples, in order, per worker - samples_per_worker = partitions.reshape(physical_nodes, devices, workers, -1) - - print("after reshape...") - - worker_sample_index = 0 # track which sample we are on. is an index per worker. - worker_download_indices = np.array( - [0] * physical_nodes - ) # track which worker we are on for downloading, per node, round-robin style - node_partial_shards = np.array([0] * physical_nodes).astype( - np.float32) # track partial shard downloads at each node - - # construct download shard OrderedSets for every worker - # list of lists of OrderedSets. outer list is per node, inner list is per worker-device - node_worker_downloads = [] - for physical_node in range(physical_nodes): - worker_downloads = [] - # want to round-robin over devices, first, then workers so we don't only download samples from one device at a time - for worker in range(workers): - for device in range(devices): - download_samples = samples_per_worker[physical_node, device, - worker, :predownload] - # take out padded samples - download_samples = np.delete(download_samples, - np.where(download_samples == -1)) - # get the shards these samples correspond to -- still want to maintain access order! - download_shards = OrderedSet(sample_to_shard[download_samples]) - worker_downloads.append(download_shards) - node_worker_downloads.append(worker_downloads) - - # if first epoch, add time so far to startup time - if epoch == 0: - startup_time += time.time() - start_time - - for batch_num in range(batches_per_epoch): - - if (batch_num + 1) % notification_batches == 0: - print('Epoch: ' + str(epoch + 1) + ' | Batch ' + str(batch_num + 1) + '/' + - str(batches_per_epoch)) - - # we round robin over workers per device. current worker is same across all nodes and devices - curr_worker = batch_num % workers - - # track how long each node takes to download the shards that the current batch needs. - node_batch_download_times = np.array([0] * physical_nodes) - - # track how many shards we downloaded in this batch total - num_downloads = 0 - - # get current samples and download samples for each node, for this batch - for physical_node in range(physical_nodes): - curr_batch_samples = samples_per_worker[physical_node, :, curr_worker, - worker_sample_index:worker_sample_index + - device_batch_size].flatten() - - #remove samples that are -1 (padded) - curr_batch_samples = np.delete(curr_batch_samples, - np.where(curr_batch_samples == -1)) - - # get the shards these samples correspond to - curr_batch_shards = set(sample_to_shard[curr_batch_samples]) - - # shards we need to download is the set difference of shards already in node and current batch shards - shards_needed = curr_batch_shards.difference(node_shards[physical_node].keys()) - - # shards already present in the node -- we need to move these to the end of the node shards (we are using them). - shards_present = curr_batch_shards.difference(shards_needed) - - # update all shards_present as accessed most recently in this node's shards - for shard in shards_present: - # moves this shard to the end of the node shards - node_shards[physical_node].setuse(shard) - - # get the set of worker downloads for the current node - worker_downloads = node_worker_downloads[physical_node] - - # push the download range for the current worker forward by device_batch_size - for device in range(devices): - # only for current workers in batch, add any potential new shards to (pre)download. - # only the current workers have their (pre)download range moved at the current step. - new_download_samples = samples_per_worker[physical_node, device, curr_worker, - worker_sample_index + - predownload:worker_sample_index + - device_batch_size + predownload] - - #remove samples that are -1 (padded) - new_download_samples = np.delete(new_download_samples, - np.where(new_download_samples == -1)) - - # get the shards these samples correspond to, maintaining access order - new_download_shards = OrderedSet(sample_to_shard[new_download_samples]) - - # get set of curr_worker downloads per device - worker_download = worker_downloads[curr_worker * devices + device] - - # add these new shards to the predownload ONLY if they are not already in the node - # won't be any duplicates in the OrderedSet of worker downloads anyways. - for shard in new_download_shards: - if shard not in node_shards[physical_node]: - worker_download.add(shard) - - # get the current worker we are starting downloads from - curr_worker_download_index = worker_download_indices[physical_node] - - # num_batch_shards is different from len(shards_needed) because there is no guarantee that the shards we need - # are immediately downloaded first. Other shards from other workers may get downloaded before we download - # the shards needed for the current batch. - num_batch_shards = 0 - - # if we need shards for the current batch, we loop through worker downloads until there are no more shards needed - while len(shards_needed) > 0: - # traverse worker_downloads until we have a worker that has samples to predownload - empty_download_counter = 0 - while len(worker_downloads[curr_worker_download_index] - ) == 0 and empty_download_counter < devices * workers: - empty_download_counter += 1 - curr_worker_download_index = (curr_worker_download_index + 1) % (devices * - workers) - - # break out of predownload loop if no workers in the node have any predownloads. - if empty_download_counter >= devices * workers: - break - - # get the worker that has samples to predownload - worker_download = worker_downloads[curr_worker_download_index] - - # first entry in predownload is the next shard the worker wants - download_shard = worker_download[0] - - if download_shard not in node_shards[physical_node]: - # handle possible eviction - if cache_limit and node_cache_usage[ - physical_node] + avg_shard_size > cache_limit: - # evict the LRU shard - node_shards[physical_node].popLRU() - # update the node cache usage - node_cache_usage[physical_node] -= avg_shard_size - num_batch_shards += 1 - num_downloads += 1 - node_cache_usage[physical_node] += avg_shard_size - # add this shard to node_shards for the node that the worker is on - # second param as False means we don't move the shard to the end of the OrderedDict (we haven't actually used the shard yet) - # but if the shard is in shards_needed, we did actually use the shard and so we move it to the end of the node shards. - node_shards[physical_node].setitem(download_shard, True) - # if shard must have been in shards_needed, remove it - shards_needed.discard(download_shard) - # if shard used to be in node eviction list, remove it -- it's now present - node_evictions[physical_node].discard(download_shard) - - # discard from worker_download - worker_download.discard(download_shard) - - # increment download index - curr_worker_download_index = (curr_worker_download_index + 1) % (devices * - workers) - - # calculate how much time we spent downloading shards for this node for the current batch only - batch_download_time = (num_batch_shards * avg_shard_size) / node_network_bandwidth - node_batch_download_times[physical_node] = batch_download_time - - # update worker download index for this node - worker_download_indices[physical_node] = curr_worker_download_index - - # The batch will only start once all nodes have all the samples for the - # batch ready. So the true start time of the batch is determined by the longest batch_download_time - # over all nodes. And that means the download_time_left for nodes that finish earlier will be longer - # and only the slowest node will have a download_time_left of avg_batch_time. - slowest_download_time = np.max(node_batch_download_times) - - # if we are on the first step, add slowest_download_time to startup time - if epoch == 0 and batch_num == 0: - startup_time += slowest_download_time - - for physical_node in range(physical_nodes): - - # we will always have the avg_batch_time to do more downloads, plus whatever amount of time this node finished early - download_time_left = avg_batch_time + (slowest_download_time - - node_batch_download_times[physical_node]) - - # get number of bytes/shards/remainder we can download in predownload_time - download_bytes_left = node_network_bandwidth * download_time_left - - # number of shards we can download right now -- - # add in the fractional part of shard that may have been downloading from previous step - download_shards_left = ( - (download_bytes_left) / avg_shard_size) + node_partial_shards[physical_node] - - # get the current worker we are starting downloads from - curr_worker_download_index = worker_download_indices[physical_node] - - # get the set of worker downloads for the current node - worker_downloads = node_worker_downloads[physical_node] - - # while we can still download a whole shard, we keep predownloading shards in the allotted time. - while download_shards_left > 1: - # traverse worker_downloads until we have a worker that has samples to predownload - empty_download_counter = 0 - while len(worker_downloads[curr_worker_download_index] - ) == 0 and empty_download_counter < devices * workers: - empty_download_counter += 1 - curr_worker_download_index = (curr_worker_download_index + 1) % (devices * - workers) - - # break out of predownload loop if no workers in the node have any predownloads. - if empty_download_counter >= devices * workers: - break - - # get the worker that has samples to predownload - worker_download = worker_downloads[curr_worker_download_index] - - # first entry in predownload is the next shard the worker wants - download_shard = worker_download[0] - - if download_shard not in node_shards[physical_node]: - # handle possible eviction - if cache_limit and node_cache_usage[ - physical_node] + avg_shard_size > cache_limit: - # evict the LRU shard - node_shards[physical_node].popLRU() - # update the node cache usage - node_cache_usage[physical_node] -= avg_shard_size - num_downloads += 1 - node_cache_usage[physical_node] += avg_shard_size - # add this shard to node_shards for the node that the worker is on - # second param is False because this shard wasn't actually needed for the current batch. doesn't count as an actual access. - node_shards[physical_node].setitem(download_shard, False) - # decrement download_shards_left because we actually downloaded something - download_shards_left -= 1 - - # discard from worker_download - worker_download.discard(download_shard) - - # increment download index - curr_worker_download_index = (curr_worker_download_index + 1) % (devices * - workers) - - # insert download_shards_left into node_partial_shards - node_partial_shards[physical_node] = download_shards_left - - # update worker download index for this node - worker_download_indices[physical_node] = curr_worker_download_index - - if generator: - yield (slowest_download_time + avg_batch_time, avg_shard_size*num_downloads) - else: - step_times.append(slowest_download_time + avg_batch_time) - shard_downloads.append(avg_shard_size*num_downloads) - - # if we are at last worker, then the sample_index per worker should shift ahead by device_batch_size - if curr_worker == workers - 1: - worker_sample_index += device_batch_size - - if not generator: - step_times = np.array(step_times) - shard_downloads = np.array(shard_downloads) - yield step_times, shard_downloads, startup_time - else: - yield startup_time - -def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray: - step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') - batch_throughput_rolling_avg = 1 / step_times_rolling_avg - batch_throughput_rolling_avg = np.concatenate((np.array([0] * (window-1)), batch_throughput_rolling_avg)) - - return batch_throughput_rolling_avg - - -def plot_simulation(step_times: NDArray, - shard_downloads: NDArray, - web: bool = True, - window: int = 10) -> Optional[bytes]: - """Plots simulation results for web UI or local script. - - Args: - step_times (NDArray): time per step, as calculated by simulation - shard_downloads (NDArray): download size (bytes) per step, as calculated by simulation - web (bool, optional): True if displaying on web UI, False if displaying through local script. Defaults to `True``. - window (int, optional): window size to calculate batch throughput over. Defaults to ``10``. - - Returns: - Optional[bytes]: bytes of plot image if ``web`` is ``True``, else plot is displayed, and returns ``None``. - """ - import matplotlib - if web: - matplotlib.use('agg') - import matplotlib.pyplot as plt - - immediate_batch_throughput = 1 / step_times - - shard_downloads_cumulative = np.cumsum(shard_downloads) - - batch_throughput_rolling_avg = get_rolling_avg_throughput(step_times, window) - - # matplotlib plot with 2 vertically stacked subplots - fig, (ax1, ax2) = plt.subplots(2, 1) - - plt.suptitle('Simulation Results', fontsize=16) - - ax1.plot(np.arange(immediate_batch_throughput.shape[0]), - immediate_batch_throughput, - color='lightblue', - label='per step throughput') - ax1.plot(np.arange(batch_throughput_rolling_avg.shape[0]), - batch_throughput_rolling_avg, - color='darkblue', - label='rolling throughput (10 step avg)') - ax1.legend() - ax1.set_ylim([0, max(immediate_batch_throughput) * 1.1]) - ax1.set_ylabel('batches/s') - ax1.set_title('batch throughput (batches/s)') - - ax2.plot(np.arange(shard_downloads_cumulative.shape[0]), - shard_downloads_cumulative, - color='blue', - label='total') - ax2.set_ylim([0, max(shard_downloads_cumulative) * 1.1]) - ax2.set_xlabel('step') - ax2.set_ylabel('cumulative download (bytes)') - ax2.set_title('network traffic (bytes)') - - fig.set_figheight(8) - fig.set_figwidth(6) - - if web: - buf = BytesIO() - fig.savefig(buf, format='png', dpi=fig.dpi) - buf.seek(0) - return buf.read() - else: - plt.show() - return None - - -def get_simulation_stats(step_times, shard_downloads, time_per_sample, device_batch_size): - """Gets simulation stats for web UI. - - Args: - step_times (NDArray): time per step, as calculated by simulation - shard_downloads (NDArray): download size (bytes) per step, as calculated by simulation - - Returns: - Tuple[float, float, float]: percent of download-limited steps, warmup time - """ - - # calculate percent of download-limited steps - min_step_time = time_per_sample * device_batch_size - all_throughput_drops = np.count_nonzero(step_times > (min_step_time)) - - # calculate warmup time (time to first max possible rolling average throughput) - max_throughput = 1 / min_step_time - rolling_avg_throughput = get_rolling_avg_throughput(step_times) - if np.max(rolling_avg_throughput) == max_throughput: - warmup_step = np.argmax(rolling_avg_throughput >= (max_throughput)) + 1 - warmup_time = np.sum(step_times[:warmup_step]) - else: - # we never hit the max possible throughput - warmup_step = rolling_avg_throughput.shape[0] - warmup_time = np.sum(step_times) - - # see if there are throughput drops after warmup so we can notify users - if warmup_step != rolling_avg_throughput.shape[0]: - # if we did hit the max throughput then we check for later drops - post_warmup_throughput_drops = np.count_nonzero(step_times[warmup_step:] > min_step_time) - else: - # since warmup was the whole time, there are no post-warmup throughput drops - post_warmup_throughput_drops = 0 - - return all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops - - - diff --git a/scripts/simulation/simulation_script.py b/scripts/simulation/simulation_script.py deleted file mode 100644 index d63f954b5..000000000 --- a/scripts/simulation/simulation_script.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Script for simulating streaming and displaying results.""" - -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -from simulation_funcs import plot_simulation, simulate - -# Input Parameters - -# dataset -shards = 20850 # number of shards -samples_per_shard = 4093 # number of samples per shard -avg_shard_size = 67092639 # average shard size (bytes) - -# training -epochs = 1 -batches_per_epoch = 3000 -device_batch_size = 16 # device batch size (samples) - -# streaming -workers = 8 # number of workers per device -canonical_nodes = 128 # number of canonical nodes -predownload = 3800 # number of samples to predownload per worker (samples) -shuffle_algo = 'py1b' # shuffling algorithm -cache_limit = None # cache limit (bytes) -shuffle_block_size = 1000000 # shuffling block size (samples) -seed = 18 # random seed - -# hardware and network -physical_nodes = 2 # number of physical nodes -devices = 8 # number of devices per node -time_per_sample = 0.0175 # time to process one sample on one device (seconds) -node_network_bandwidth = 2e9 # network bandwidth per node (bytes/s) - -# ---------------------------------------------- # - -# simulate step times and shard downloads given the inputs -results = simulate(shards, samples_per_shard, avg_shard_size, - device_batch_size, time_per_sample, batches_per_epoch, - epochs, physical_nodes, devices, node_network_bandwidth, - workers, canonical_nodes, predownload, cache_limit, - shuffle_algo, shuffle_block_size, seed) - -step_times, shard_downloads = next(results) - -# plot results -plot_simulation(step_times, shard_downloads, web=False) diff --git a/scripts/simulation/simulation_testing.py b/scripts/simulation/simulation_testing.py deleted file mode 100644 index a0060d502..000000000 --- a/scripts/simulation/simulation_testing.py +++ /dev/null @@ -1,124 +0,0 @@ -# testing script for simulator -# compares experimental data from wandb with simulation data. - -import wandb -import matplotlib.pyplot as plt -import numpy as np -import yaml -from simulation_funcs import simulate -import pandas as pd - -api = wandb.Api() - -project_id = "mosaic-ml/streaming-shuffling-algo" -project_runs = api.runs(path=project_id, per_page=300) -project_runs_list = [run.id for run in project_runs] -skip = 0 - - -# C4 neox compressed from OCI parameters -shards = 20850 -samples_per_shard = 4093 -avg_shard_size = 67092639 -compressed_shard_size = 16000000 -compression_ratio = compressed_shard_size / avg_shard_size -epochs = 1 -time_per_sample = 0.0175 -node_network_bandwidth = 2e9 -throughput_window = 10 - -def get_similarity_percentage(real, sim): - real_copy = real.reshape(1, -1) - sim_copy = sim.reshape(1, -1) - merged = np.concatenate((real_copy, sim_copy), axis=0) - similarities = np.abs(real-sim)/np.max(merged, axis=0) - nanmean = np.nanmean(similarities) - return 1 - nanmean - -for run_id in project_runs_list[skip:]: - - run = api.run(f"{project_id}/{run_id}") - - print(run.name) - - summary = run.summary - config = run.config - - if '_step' not in summary: - print("skipping unsuccessful run") - continue - - # get parameters from run config and summary - batches_per_epoch = summary['_step'] - devices = int(config["num_gpus_per_node"]) - physical_nodes = int(config['n_gpus']/devices) - # device_batch_size set for each run - device_batch_size = int(config['global_train_batch_size']/(physical_nodes*devices)) - canonical_nodes = int(config['num_canonical_nodes']) - workers = int(config["train_loader"]["num_workers"]) - predownload = int(config["train_loader"]["dataset"]["predownload"]) - cache_limit = None - if "cache_limit" in config["train_loader"]["dataset"]: - cache_limit = config["train_loader"]["dataset"]["cache_limit"] - shuffle_algo = None - if "shuffle_algo" in config["train_loader"]["dataset"]: - shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] - shuffle_block_size = config["train_loader"]["dataset"]["shuffle_block_size"] - seed = config['seed'] - - # get step timestamps, real throughput, and network use from the run - step_timestamps = run.history(samples=batches_per_epoch, keys=["_timestamp"], pandas=True) - real_batch_throughput = run.history(samples=batches_per_epoch-throughput_window, keys=["throughput/batches_per_sec"], pandas=True) - - real_network_use = run.history(stream="system", pandas=True)[["_timestamp", "system.network.recv"]] - - # merge real_network_use with step_timestamps - merged_network_use = pd.merge_asof(real_network_use, step_timestamps, on="_timestamp", direction="nearest") - - # simulate throughput and network use given the inputs - - result = simulate(shards, samples_per_shard, avg_shard_size, - device_batch_size, time_per_sample, batches_per_epoch, - epochs, physical_nodes, devices, node_network_bandwidth, - workers, canonical_nodes, predownload, cache_limit, - shuffle_algo, shuffle_block_size, seed) - - step_times, shard_downloads = next(result) - - immediate_batch_throughput = 1 / step_times - - shard_downloads_cumulative = np.cumsum(shard_downloads) - shard_downloads_steps = np.arange(shard_downloads.shape[0]) - sim_downloads = pd.DataFrame({"_step": shard_downloads_steps, "sim_downloads": shard_downloads_cumulative}) - # merge simulated downloads with real downloads dataframe - merged_network_use = pd.merge_asof(merged_network_use, sim_downloads, on="_step", direction="nearest") - - step_times_rolling_avg = np.convolve(step_times, np.ones(throughput_window) / throughput_window, mode='valid')[:-1] - batch_throughput_rolling_avg = 1 / step_times_rolling_avg - sim_throughput = pd.DataFrame({"_step": throughput_window + np.arange(batch_throughput_rolling_avg.shape[0]), "sim_throughput": batch_throughput_rolling_avg}) - merged_throughput = pd.merge_asof(real_batch_throughput, sim_throughput, on="_step", direction="nearest") - - # get similarity scores - throughput_similarity = get_similarity_percentage(merged_throughput["throughput/batches_per_sec"].to_numpy(), merged_throughput["sim_throughput"].to_numpy()) - network_similarity = get_similarity_percentage(physical_nodes*(merged_network_use["system.network.recv"].to_numpy()), compression_ratio*(merged_network_use["sim_downloads"].to_numpy())) - - # print params and results to easily paste to spreadsheet - print(run.name, seed, canonical_nodes, physical_nodes, predownload, shuffle_algo, shuffle_block_size, cache_limit, batches_per_epoch, throughput_similarity, network_similarity) - - fig, (ax1, ax2) = plt.subplots(2, 1) - - ax1.set_title("throughput - score: " + str(throughput_similarity)) - ax1.plot(merged_throughput["_step"], merged_throughput["throughput/batches_per_sec"], color="red", label="real") - ax1.plot(merged_throughput["_step"], merged_throughput["sim_throughput"], color="blue", label="sim") - ax1.legend() - - ax2.set_title("network use - score: " + str(network_similarity)) - # wandb only logs network use for node 0. multiply by number of nodes to get total network use - ax2.plot(merged_network_use["_timestamp"], physical_nodes*merged_network_use["system.network.recv"], color="red", label="real") - # simulation assumes all shards are downloaded uncompressed (overestimates). multiply by compression ratio to get true network use - ax2.plot(merged_network_use["_timestamp"], compression_ratio*merged_network_use["sim_downloads"], color="blue", label="sim") - ax2.legend() - - fig.set_figheight(8) - - plt.show() \ No newline at end of file diff --git a/scripts/simulation/simulation_ui.py b/scripts/simulation/simulation_ui.py deleted file mode 100644 index ed35c1c33..000000000 --- a/scripts/simulation/simulation_ui.py +++ /dev/null @@ -1,191 +0,0 @@ -# simulator ui using streamlit - -import streamlit as st -import numpy as np -import altair as alt -import pandas as pd -from simulation_funcs import simulate, get_simulation_stats -from streaming.base.util import number_abbrev_to_int, bytes_to_int - - -# set up page -st.set_page_config(layout="wide") -col1, space, col2 = st.columns((8, 1, 8)) -col2.title("Streaming Simulator") -col2.write("Enter run parameters in the left panel.") -col2.text("") -progress_bar = col1.progress(0) -status_text = col1.empty() -col1.text("") -throughput_plot = col2.empty() -network_plot = col2.empty() -throughput_window = 10 - -def get_chart(data, throughput=True): - hover = alt.selection_point( - fields=["step"], - nearest=True, - on="mouseover", - empty=False, - ) - - lines = ( - alt.Chart(data, title="Throughput (per step and " + str(throughput_window) + "-step rolling average)") - .mark_line() - .encode( - x="step", - y="throughput (batches/s)", - color="measurement" - ) - ) if throughput else ( - alt.Chart(data, title="Cumulative Network Usage") - .mark_line() - .encode( - x="step", - y="cumulative network usage (bytes)" - ) - ) - - # Draw points on the line, and highlight based on selection - points = lines.transform_filter(hover).mark_circle(size=65) - - # Draw a rule at the location of the selection - tooltips = ( - alt.Chart(data) - .mark_rule() - .encode( - x="step", - y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)", - opacity=alt.condition(hover, alt.value(0.3), alt.value(0)), - tooltip=[ - alt.Tooltip("step", title="Step"), - alt.Tooltip("throughput (batches/s)" if throughput else "cumulative network usage (bytes)", title="Throughput" if throughput else "Network Usage"), - ], - ) - .add_params(hover) - ) - return (lines + points + tooltips).interactive() - -def submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches_per_epoch, device_batch_size, - workers, canonical_nodes, predownload, shuffle_algo, cache_limit, shuffle_block_size, - seed, physical_nodes, devices, time_per_sample, node_network_bandwidth): - gen_sim = simulate(shards, samples_per_shard, avg_shard_size, - device_batch_size, time_per_sample, batches_per_epoch, - epochs, physical_nodes, devices, node_network_bandwidth, - workers, canonical_nodes, predownload, cache_limit, - shuffle_algo, shuffle_block_size, seed, True) - - gen_step_times = [] - gen_shard_downloads = [] - rolling_throughput_data = [] - immediate_throughput_data = [] - network_data = [] - steps = [] - time_to_first_batch = 0 - for i, result in enumerate(gen_sim): - # if result length is 2, then we have (step_time, shard_download). otherwise is just returning startup time. - if type(result) != np.float64: - step_time, shard_download = result - gen_step_times.append(step_time) - gen_shard_downloads.append(shard_download) - # plot throughput once we have enough samples for the window - if i >= throughput_window - 1: - step_time_window = np.array(gen_step_times[-throughput_window:]) - throughput = 1/np.mean((step_time_window)) - rolling_throughput_data.append(throughput) - else: - rolling_throughput_data.append(0) - immediate_throughput_data.append(1/step_time) - # plot network usage - cumulative_shard_download = np.sum(np.array(gen_shard_downloads)) - network_data.append(cumulative_shard_download) - steps.append(i+1) - else: - time_to_first_batch = result - - # update plots and percentages at regular intervals - plot_interval = (batches_per_epoch*epochs) // 15 - if i == 1 or i % plot_interval == 0 or i == batches_per_epoch * epochs - 1: - rolling_throughput_df = pd.DataFrame({"step": steps, "measurement": [" rolling avg"]*len(rolling_throughput_data), "throughput (batches/s)": rolling_throughput_data}) - immediate_throughput_df = pd.DataFrame({"step": steps, "measurement": ["per step"]*len(immediate_throughput_data), "throughput (batches/s)": immediate_throughput_data}) - throughput_df = pd.concat([immediate_throughput_df, rolling_throughput_df]) - network_df = pd.DataFrame({"step": steps, "cumulative network usage (bytes)": network_data}) - throughput_plot.altair_chart(get_chart(throughput_df, True), use_container_width=True) - network_plot.altair_chart(get_chart(network_df, False), use_container_width=True) - # update progress bar and text - percentage = int(100*(i+1) / (batches_per_epoch * epochs)) - status_text.text("%i%% Complete" % percentage) - progress_bar.progress(percentage) - - gen_step_times = np.array(gen_step_times) - gen_shard_downloads = np.array(gen_shard_downloads) - - all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = get_simulation_stats(gen_step_times, gen_shard_downloads, time_per_sample, device_batch_size) - - if warmup_step == batches_per_epoch*epochs: - # display error if the warmup phase is the whole run, meaning that we never hit peak throughput. - col2.error('This configuration is severely bottlenecked by downloading. The run will not be performant.', icon="🚨") - elif post_warmup_throughput_drops: - # display warning if post-warmup throughput drops are more than 10% of the run. - col2.warning('This configuration experiences some downloading-related slowdowns even after warmup.', icon="⚠️") - col2.write("**{0} steps**, or **{1:.1f}%** of all steps, waited for shard downloads.".format(all_throughput_drops, 100*all_throughput_drops/(batches_per_epoch*epochs))) - if warmup_step != batches_per_epoch*epochs: - # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) - col2.write("There were **{} steps** that waited for shard downloads after the warmup period.".format(post_warmup_throughput_drops)) - col2.write("Estimated time to first batch: **{0:.2f} s**".format(time_to_first_batch)) - col2.write("Estimated warmup time: **{0:.2f} s**".format(warmup_time)) - - -with col1.form("my_form"): - - submitted = st.form_submit_button("Simulate Run", use_container_width=True) - st.text("") - - col3, col4 = st.columns(2) - - # dataset - col3.write("**Dataset Parameters**") - shards = col3.number_input('number of shards', step=1, value=20850, help="number of total shards across your whole dataset.") - samples_per_shard = col3.number_input('samples per shard', step=1, value=4093, help="average number of samples contained in each shard. the `index.json` file can help estimate this.") - avg_shard_size = col3.text_input('average shard size (bytes)', value="67MB", help="average size, in bytes, of a single shard. the `index.json` file can help estimate this.") - col3.text("") - - # training - col4.write("**Training Parameters**") - epochs = col4.number_input('number of epochs', step=1, value=1, help="number of epochs for this run.") - batches_per_epoch = col4.text_input('batches per epoch', value="3k", help="number of batches per epoch for this run.") - batches_per_epoch = number_abbrev_to_int(batches_per_epoch) - device_batch_size = col4.number_input('device batch size', step=1, value=16, help="number of samples per device (GPU) per batch. the global batch size is `device_batch_size * devices_per_node * physical_nodes`") - col4.text("") - - # hardware and network - col3.write("**Hardware and Network Parameters**") - physical_nodes = col3.number_input('number of physical nodes', step=1, value=2, help="number of physical nodes for this run. a node typically consists of 8 devices (GPUs).") - devices = col3.number_input('devices per node', step=1, value=8, help="number of devices (GPUs) per node for this run. there are typically 8 devices per node.") - time_per_sample = col3.number_input('process time per sample (s)', step = 0.0005, value=0.0175, format="%.4f", help="time for one device to process one sample from your dataset.") - node_network_bandwidth = col3.text_input('network bandwidth per node (bytes/s)', value="2GB", help="network bandwidth available to each node. in practice, network bandwidth is variable and is affected by many factors, including cluster demand.") - col3.text("") - - # streaming - col4.write("**Streaming Parameters**") - workers = col4.number_input('workers per device', step=1, value=8, help="number of dataloader workers per device (GPU).") - canonical_nodes = col4.number_input('number of canonical nodes', step=1, value=8, help="number of canonical nodes to split your dataset into. a canonical node is a bucket of shards that is assigned to a particular physical node.") - predownload = col4.text_input('predownload per worker (samples)', value=64, help="number of samples ahead each worker should download. predownload does not occur before the first batch; rather, it occurs while training is ongoing.") - shuffle_algo = col4.selectbox('shuffling algorithm', ["py1b", "py1br", "py1e", "py1s", "py2s", "naive", "None"], help="shuffling algorithm to use for this run. your shuffle parameters may affect model training.") - if shuffle_algo == "None": - shuffle_algo = None - cache_limit = col4.text_input('cache limit (bytes)', value="None", help="cache limit per node for this run. setting cache limit too low will impact throughput.") - if cache_limit == "None": - cache_limit = None - else: - cache_limit = bytes_to_int(cache_limit) - shuffle_block_size = col4.text_input('shuffle block size (samples)', value="16M", help="shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly shuffled inside each bucket of shards (aka canonical node).") - seed = col4.number_input('random seed', step=1, value=42, help="random seed for shuffling.") - col4.text("") - - if submitted: - submit_simulation(shards, samples_per_shard, avg_shard_size, epochs, batches_per_epoch, device_batch_size, - workers, canonical_nodes, predownload, shuffle_algo, cache_limit, shuffle_block_size, - seed, physical_nodes, devices, time_per_sample, node_network_bandwidth) - - \ No newline at end of file diff --git a/setup.py b/setup.py index db4f28d6c..cd8258fd0 100644 --- a/setup.py +++ b/setup.py @@ -78,9 +78,6 @@ 'pydantic==2.1.1', 'uvicorn==0.23.2', 'pytest-split==0.8.1', - 'sortedcollections==2.1.0', - 'streamlit==1.26.0', - 'altair==5.0.1', ] extra_deps['docs'] = [ @@ -97,6 +94,14 @@ 'sphinx-tabs==3.4.1', ] +extra_deps['simulator'] = [ + 'sortedcollections>=2.1.0', + 'streamlit>=1.26.0', + 'altair>=5.1.1', + 'omegaconf>=2.3.0', + 'PyYAML>=6.0', +] + extra_deps['all'] = sorted({dep for deps in extra_deps.values() for dep in deps}) package_name = os.environ.get('MOSAIC_PACKAGE_NAME', 'mosaicml-streaming') diff --git a/simulator/README.md b/simulator/README.md new file mode 100644 index 000000000..e3a40b57b --- /dev/null +++ b/simulator/README.md @@ -0,0 +1,50 @@ +# 🤖 Streaming Simulator +A simulator for throughput and network use with MosaicML's [Streaming](https://github.com/mosaicml/streaming). The simulator allows you to: +- Plan runs and anticipate issues beforehand +- Find optimal run configurations +- Debug issues with underperforming runs +- Better understand the impact of different configurations + +## 🚀 Getting Started +Run the commands below to get simulating! +``` +git clone https://github.com/mosaicml/streaming.git +cd streaming +pip install ".[simulator]" +make simulator +``` +## 🔑 Key Features + +### Throughput +Throughput is estimated for the duration of the run and is displayed as the simulation progresses. We estimate throughput by iterating over the samples of the dataset in order, and performing shard downloads based on an estimate of network bandwidth. The 10-step rolling average is displayed. + +![Throughput Graph](imgs/throughput.png) + +### Network Downloads +Cumulative network downloads are also estimated for the run and displayed. It is calculated in conjunction with throughput. If shards are compressed, we assume they are downloaded in compressed form and immediately uncompressed. + +![Downloads Graph](imgs/downloads.png) + +### Simulation Stats +We also provide various useful statistics from the simulation, such as: +- Minimum cache limit (i.e., maximum space used by live shards) +- Steps slowed down by shard downloads +- Estimated time to first batch +- Estimated warmup time (i.e., time until throughput maximized) + +![Simulation Stats](imgs/stats.png) + +### Shuffle Quality +You can choose to evaluate the quality of different shuffling algorithms for your run. We provide an estimate of shuffle quality based on the entropy calculated over the probability distribution of differences between neighboring sample indices and shard indices of the dataset. *These shuffle quality metrics are noisy and may not reflect the true strength of a shuffle.* + +![Shuffle Quality Toggle](imgs/shuffle_quality_toggle.png) + +![Shuffle Quality Graph](imgs/shuffle_quality_graph.png) + +### Yaml Support +Yaml files that follow MosaicML conventions can be uploaded and simulated as well. Simply click the toggle, enter any needed additional information, and see your results. Parameters can also be modified to quickly test out configurations. + +![Yaml Quality Toggle](imgs/yaml_toggle.png) + +## 💬 Contact +If you have problems, questions, or suggestions, please reach out to the MosaicML team on our [community slack channel](https://mosaicml.me/slack). diff --git a/simulator/core/create_index.py b/simulator/core/create_index.py new file mode 100644 index 000000000..a4584101c --- /dev/null +++ b/simulator/core/create_index.py @@ -0,0 +1,81 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Create dataset index file from input parameters.""" +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +import json +from core.simulation_dataset import SimulationDataset +from streaming.base import Stream +from typing import Optional +import random +import string + +def get_random_foldername(): + return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(16)) + +def create_stream_index(shards: int, + samples_per_shard: int, + avg_raw_shard_size: int, + avg_zip_shard_size: Optional[int]) -> Stream: + """Create dataset index file from input parameters. + + Args: + shards (int): Number of shards. + samples_per_shard (int): Number of samples per shard. + avg_raw_shard_size (int): Average raw shard size. + avg_zip_shard_size (int): Average compressed shard size. + Returns: + local path to created index file for stream. + """ + index_data = { + "version": 2, + } + + shards_list = [] + for shard_id in range(shards): + shard_data = { + "column_encodings": [], + "column_names": [], + "column_sizes": [], + "format": "mds", + "raw_data": { + "basename": "shard."+str(shard_id)+".mds", + "bytes": avg_raw_shard_size, + "hashes": {} + }, + "hashes": [], + "samples": samples_per_shard, + "size_limit": avg_raw_shard_size, + "version": 2, + "zip_data": None, + "compression": None + } + if avg_zip_shard_size is not None: + shard_data["zip_data"] = { + "basename": "shard."+str(shard_id)+".mds.zstd", + "bytes": avg_zip_shard_size, + "hashes": {} + } + shard_data["compression"] = "zstd:16" + shards_list.append(shard_data) + + index_data["shards"] = shards_list + + # Try making the directory for the stream's index.json file + foldername = get_random_foldername() + "_indexcreated" + try: + os.mkdir(foldername) + except FileExistsError: + print("Folder already exists, trying again...") + foldername = get_random_foldername() + os.mkdir(foldername) + + with open(foldername+'/index.json', 'w') as f: + json.dump(index_data, f) + f.close() + + return os.path.join(foldername, 'index.json') \ No newline at end of file diff --git a/scripts/simulation/last_used_ordered_set.py b/simulator/core/last_used_ordered_set.py similarity index 96% rename from scripts/simulation/last_used_ordered_set.py rename to simulator/core/last_used_ordered_set.py index 185817a7d..9dff38435 100644 --- a/scripts/simulation/last_used_ordered_set.py +++ b/simulator/core/last_used_ordered_set.py @@ -27,7 +27,7 @@ def setitem(self, key: Any, move_to_end: bool = True): def popLRU(self): """Pop the least recently used item (located at the front).""" - self.popitem(last=False)[0] + return self.popitem(last=False)[0] def setuse(self, key: Any): """Mark an item as used, moving it to the end. diff --git a/simulator/core/main.py b/simulator/core/main.py new file mode 100644 index 000000000..c16626953 --- /dev/null +++ b/simulator/core/main.py @@ -0,0 +1,243 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Functions for simulating streaming and displaying results.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + + +import numpy as np +from numpy.typing import NDArray +from core.shard_downloads import simulate_shard_downloads, run_cache_limit +from core.sim_time import Time +from typing import Tuple, Union +import time + +from core.simulation_dataset import SimulationDataset +from core.node_tracker import NodeTracker +from core.utils import get_batches_epochs, bytes_to_time, time_to_bytes + +def simulate(dataset: SimulationDataset, + time_per_sample: float, + node_network_bandwidth: Union[float,str], + generator: bool = False, + max_duration: Time = None + ) -> Union[Tuple[int, float, int], + Tuple[NDArray, NDArray, float, int], + Tuple[float, int]]: + """Simulates step time and downloads using streaming for the specified input parameters. + + Key Notes and Assumptions: + + * assume that batch time is solely made up of two things: batch processing time and batch + shard download wait time + * loop through workers round-robin style for batches and for downloads + * assume each node has a separate network bandwidth + * the batch has to wait until all nodes have downloaded the shards containing batch samples. + * for shard eviction itself, use LRU shard eviction to take out the least recently used + shard, per node. + * shards are shared across devices on a single node, but nodes do not share shards between + each other. + * if a shard is unavailable, we wait for some worker to download it. + * if a shard is available in a node, we just use it. + + Args: + dataset (SimulationDataset): SimulationDataset object created based on input params/yaml + time_per_sample (float): time to process one sample on one device (seconds) + node_network_bandwidth (Union[float, str]): network bandwidth per node (bytes/s) + generator (bool): True if we yield throughput and shard_download one step at a time. + max_duration (Time, optional): max duration of simulation. Defaults to ``None``. + + Returns: + Union[Tuple[int, int], Tuple[NDArray, NDArray], np.float64]: either a Tuple of step_time, + shard_download, or a Tuple all step_times, shard_downloads or the startup time. + """ + + # tracking startup time, which includes SimulationDataset instantiation time. + start_time = time.time() + startup_time = dataset.get_instantiation_time() + + # Get batches, epochs, total batches from dataset and provided time info. + batches_per_epoch, epochs, total_batches = get_batches_epochs(dataset, max_duration) + + # Retrieve streaming and dataset information from SimulationDataset. + physical_nodes = dataset.get_nodes() + devices = dataset.get_devices() + workers = dataset.get_workers() + device_batch_size = dataset.get_batch_size() + predownload = dataset.get_predownload() + cache_limit = dataset.get_cache_limit() + total_shards = dataset.get_num_shards() + raw_shard_sizes = dataset.get_raw_shard_sizes() + zip_shard_sizes = dataset.get_zip_shard_sizes() + # dataset's spanner object maps global sample id to shard id. + sample_to_shard = dataset.get_spanner() + + # track shard access ranges to compute minimum needed cache limit for the run. + shard_access_starts = np.full(total_shards, -1) + shard_access_ends = np.full(total_shards, -1) + + # Initialize NodeTracker objects for each node. These keep track of shards, worker downloads, + # cache usage, etc. for each node. + nodes = [] + for _ in range(physical_nodes): + nodes.append(NodeTracker(workers, devices, predownload, device_batch_size, cache_limit)) + + # Time for the global batch is just device batch size * time per sample. + # We assume all devices process their microbatch perfectly in parallel. + avg_batch_process_time = device_batch_size * time_per_sample + + notification_batches = int(batches_per_epoch) / 20 + + # Simulate training by looping over epochs and batches. + + # Track time and downloads at each step. + step_times = [] + step_downloads = [] + + for epoch in range(epochs): + + # Get the samples, divided up per node, for this epoch. + samples_per_node = dataset.get_samples_per_node(epoch, 0) + + # Set the samples for each node for this epoch. + for node_id, node in enumerate(nodes): + node.samples = samples_per_node[node_id] + node.initialize_worker_downloads(sample_to_shard) + + # Track which sample we are currently on, as a worker id. We round-robin over workers. + worker_sample_index = 0 + + # if first epoch, add time so far to startup time + if epoch == 0: + startup_time += time.time() - start_time + + # Iterate over batches + for batch in range(batches_per_epoch): + + step_num = batch + (batches_per_epoch * epoch) + + # If we are at the last batch, exit. + if step_num >= total_batches: + break + + # Print progress every notification_batches interval. + if (batch + 1) % notification_batches == 0: + print('Epoch: ' + str(epoch + 1) + ' | Batch ' + str(batch + 1) + '/' + + str(batches_per_epoch)) + + # We round-robin over workers per device. The current batch's worker is the same + # across every device. + curr_worker = batch % workers + # Track how long each node takes to download the shards that the current batch needs. + node_batch_download_times = np.array([0] * physical_nodes) + # Track how many total bytes are downloaded in this batch by all nodes. + downloaded_bytes = 0 + + # Get current samples and download samples for each node, for this batch. + for node_id, node in enumerate(nodes): + + shards_needed, shards_present = node.get_current_batch_shards(curr_worker, + worker_sample_index, + sample_to_shard) + # Mark all shards present as accessed most recently in this node. + node.set_shards_used(shards_present, shard_access_ends, step_num) + # Push the predownload for the current batch workers ahead by device_batch_size. + node.update_worker_predownloads(curr_worker, worker_sample_index, sample_to_shard) + # Track bytes downloaded by this node. + node_downloaded_bytes = 0 + + # Because we assume downloads also round-robin over workers, we can download shards + # other than the current batch's shards while looking for current batch's shards. + while len(shards_needed) > 0: + download_outcome, download_size = \ + simulate_shard_downloads(node, + raw_shard_sizes, + zip_shard_sizes, + current_batch_downloads=True, + shard_access_starts=shard_access_starts, + shard_access_ends=shard_access_ends, + step_num=step_num, + cache_limit=cache_limit, + shards_needed=shards_needed) + if download_outcome == "downloaded": + node_downloaded_bytes += download_size + downloaded_bytes += download_size + elif download_outcome == "present": + # If the shard was already present in the node, continue downloading. + pass + else: + # If no shard downloads are left in the node, stop downloading. + break + + # Calculate how much time this node spent downloading shards + node_batch_download_times[node_id] = bytes_to_time(node_downloaded_bytes, + node_network_bandwidth) + + # The node that took the longest to download shards is the bottleneck. All other nodes + # use the extra time to continue downloading. + slowest_download_time = np.max(node_batch_download_times) + + # If we are on the first step, add slowest_download_time to startup time + if epoch == 0 and batch == 0: + startup_time += slowest_download_time + + # Iterate over nodes again to continue downloading shards. + for node_id, node in enumerate(nodes): + # The download time each node has is the avg_batch_process_time plus the extra time + # the node has from finishing earlier than the slowest node. + download_time_left = avg_batch_process_time + (slowest_download_time - + node_batch_download_times[node_id]) + # Get number of bytes we can download in download_time_left. + # We also include any partially downloaded bytes from previous steps. + download_bytes_left = time_to_bytes(download_time_left, node_network_bandwidth) + \ + node.partial_shard_bytes + + while True: + download_outcome, download_size = \ + simulate_shard_downloads(node, + raw_shard_sizes, + zip_shard_sizes, + current_batch_downloads=False, + shard_access_starts=shard_access_starts, + shard_access_ends=shard_access_ends, + step_num=step_num, + cache_limit=cache_limit, + download_bytes_left=download_bytes_left) + if download_outcome == "downloaded": + downloaded_bytes += download_size + download_bytes_left -= download_size + elif download_outcome == "present": + pass + else: + # If no shard downloads are left in the node, or we could only partially + # download a shard, stop downloading. + break + + # Yield or store step number, time and download for this step + if generator: + yield step_num, slowest_download_time + avg_batch_process_time, downloaded_bytes + else: + step_times.append(slowest_download_time + avg_batch_process_time) + step_downloads.append(downloaded_bytes) + + # Increment worker_sample_index by device_batch_size if we are at the last worker. + # As we round-robin over workers, the sample index per worker is increased as we loop + # through all workers. + if curr_worker == workers - 1: + worker_sample_index += device_batch_size + + # Simulation is finished. Calculate needed cache limit from shard access ranges. + min_cache_limit = run_cache_limit(shard_access_starts, shard_access_ends, + raw_shard_sizes, nodes) + + # Yield results. + if not generator: + step_times = np.array(step_times) + step_downloads = np.array(step_downloads) + yield step_times, step_downloads, startup_time, min_cache_limit + else: + yield startup_time, min_cache_limit \ No newline at end of file diff --git a/simulator/core/node_tracker.py b/simulator/core/node_tracker.py new file mode 100644 index 000000000..0e2f93be4 --- /dev/null +++ b/simulator/core/node_tracker.py @@ -0,0 +1,209 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Class for tracking node information during simulation.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from core.last_used_ordered_set import LastUsedOrderedSet +from core.utils import remove_padded_samples +from numpy.typing import NDArray +from sortedcollections import OrderedSet +from streaming.base.spanner import Spanner +import numpy as np +from typing import Optional, Tuple + +class NodeTracker(): + + def __init__(self, workers: int, devices: int, predownload: int, + device_batch_size: int, cache_limit: Optional[int] = None): + """Tracker for node information during simulation. + + Args: + node_id (int): The node ID. + workers (int): The number of workers. + devices (int): The number of devices. + predownload (int): The number of samples to predownload. + device_batch_size (int): The device batch size. + sample_to_shard (Spanner): The mapping from samples to shards. + cache_limit (Optional[int]): The cache limit for the node. Defaults to None. + """ + self.shards = LastUsedOrderedSet() + self.all_shards = set() + self.cache_usage = 0 + self.partial_shard_bytes = 0 + self.worker_download_index = 0 + self.devices = devices + self.workers = workers + self.total_workers = workers * devices + self.device_batch_size = device_batch_size + self.predownload = predownload + self.cache_limit = cache_limit + self.worker_downloads = [] + + # Use the set_epoch_samples method every epoch to set the node's samples. + self.samples = None + + def initialize_worker_downloads(self, sample_to_shard: Spanner): + """Initialize the worker downloads.""" + # For downloads, we round-robin over devices first, then workers. + if self.samples is None: + raise ValueError("Must set samples before initializing worker downloads.") + else: + for worker in range(self.workers): + for device in range(self.devices): + download_samples = remove_padded_samples(self.samples[device, worker, + :self.predownload]) + # Get the shards these samples correspond to, maintaining access order. + download_shards = OrderedSet([sample_to_shard[sample] + for sample in download_samples]) + self.worker_downloads.append(download_shards) + + def set_shards_used(self, shards: set, + shard_access_ends: NDArray, + step_num: int): + """Set a set of shards as used. + + Args: + shards (set): The shards to set as used. + shard_access_ends (NDArray): The shard access end steps. + step_num (int): The current step number. + """ + for shard in shards: + self.shards.setuse(shard) + # For any shard access, we are accessing the shard so we need the shard until + # at least the next step begins. Adding 0.5 ensures that we evict shards + # after they are used for the last time, but before they are replaced by + # new downloads in the next step. + shard_access_ends[shard] = step_num + 0.5 + + def add_shard(self, shard: int, used: bool = True): + """Add a shard to the node. + + Args: + shard (int): The shard to add. + used (bool): Whether the shard is used when added. + """ + self.shards.setitem(shard, used) + self.all_shards.add(shard) + + def get_all_shards(self): + """Get all the shards in the node.""" + return self.all_shards + + def evict_shard(self) -> int: + """Evict a shard. + + Returns: + int: The evicted shard. + """ + evicted_shard = self.shards.popLRU() + return evicted_shard + + def evict_until_satisfied(self, incoming_shard_size: int, raw_shard_sizes: NDArray): + """Evict shards until the node has enough space to download the incoming shard. + + Args: + incoming_shard_size (int): The size of the incoming shard. + raw_shard_sizes (NDArray): The raw shard sizes. + """ + while self.cache_usage + incoming_shard_size > self.cache_limit: + evicted_shard = self.evict_shard() + self.cache_usage -= raw_shard_sizes[evicted_shard] + + def increment_worker_download_index(self): + """Increment the worker download index.""" + self.worker_download_index = (self.worker_download_index + 1) % \ + (self.workers * self.devices) + + def get_worker_download(self, + worker: Optional[int] = None, + device: Optional[int] = None, + index: Optional[int] = None) -> OrderedSet: + """Get the shard downloads for a worker on a specific device. + + Args: + worker (Optional[int]): The worker index. + device (Optional[int]): The device index the worker is on. + index (Optional[int]): The index of the worker download for direct access. + Returns: + OrderedSet: The shard downloads, in order, for this worker. + """ + if index is not None: + return self.worker_downloads[index] + elif worker is not None and device is not None: + return self.worker_downloads[worker * self.devices + device] + else: + raise ValueError("Must specify either index, or worker and device.") + + def get_current_batch_shards(self, worker: int, + worker_sample_index: int, + sample_to_shard: Spanner) -> Tuple[set, set]: + """Get this node's shards for the current batch. + + Args: + worker (int): The worker. + worker_sample_index (int): The worker sample index. + sample_to_shard (Spanner): The mapping from samples to shards. + Returns: + Tuple[set, set]: shard ids needed by node, shard ids present in node. + """ + batch_samples = remove_padded_samples(self.samples[:, worker, + worker_sample_index: + worker_sample_index + self.device_batch_size].flatten()) + batch_shards = set([sample_to_shard[sample] for sample in batch_samples]) + shards_needed = batch_shards.difference(self.shards.keys()) + shards_present = batch_shards.difference(shards_needed) + return shards_needed, shards_present + + def get_next_worker_with_downloads(self) -> Optional[OrderedSet]: + """Get the next worker with samples to download. + + Returns: + Optional[OrderedSet]: The next worker's shard downloads, or None if no workers have + samples to download. + """ + empty_download_counter = 0 + worker_download = self.get_worker_download(index=self.worker_download_index) + while len(worker_download) == 0: + empty_download_counter += 1 + self.increment_worker_download_index() + worker_download = self.get_worker_download(index=self.worker_download_index) + + # No workers in the node have samples to download. + if empty_download_counter >= self.total_workers: + return None + + return worker_download + + def update_worker_predownloads(self, worker: int, + worker_sample_index: int, + sample_to_shard: Spanner): + """Get the worker predownload samples for a worker and device. + + Args: + worker (int): The current batch worker index. + worker_sample_index (int): The worker sample index. + sample_to_shard (Spanner): The mapping from samples to shards. + """ + for device in range(self.devices): + new_download_samples = remove_padded_samples(self.samples[device, worker, + worker_sample_index + self.predownload: + worker_sample_index + self.device_batch_size + + self.predownload]) + + # Want to maintain the shard access order. + new_download_shards = OrderedSet([sample_to_shard[sample] + for sample in new_download_samples]) + + worker_downloads = self.get_worker_download(worker=worker, device=device) + + # Add in new shards to the worker's shard downloads only if the node does not yet have it. + for shard in new_download_shards: + if shard not in self.shards: + worker_downloads.add(shard) + + \ No newline at end of file diff --git a/simulator/core/shard_downloads.py b/simulator/core/shard_downloads.py new file mode 100644 index 000000000..834f8875a --- /dev/null +++ b/simulator/core/shard_downloads.py @@ -0,0 +1,133 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Functions for simulating shard downloads.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from core.node_tracker import NodeTracker +from numpy.typing import NDArray +from typing import Optional, Tuple + +def simulate_shard_downloads(node: NodeTracker, + raw_shard_sizes: NDArray, + zip_shard_sizes: NDArray, + current_batch_downloads: bool, + shard_access_starts: NDArray, + shard_access_ends: NDArray, + step_num: int, + cache_limit: Optional[int] = None, + shards_needed: Optional[set] = None, + download_bytes_left: Optional[int] = None) -> Tuple[bool, int]: + + worker_download = node.get_next_worker_with_downloads() + if worker_download is None: + # No workers have shards to download. + return ("empty", 0) + + # Proceed with downloading the shard for this worker. + download_shard = worker_download[0] + + # Get the raw and zip sizes, in bytes, of the shard. + shard_raw_size = raw_shard_sizes[download_shard] + shard_zip_size = zip_shard_sizes[download_shard] + # If shard is compressed, we download the zipped size. Otherwise, download raw size. + download_size = shard_zip_size or shard_raw_size + + # If we are not downloading for the current batch, we need to check if the download bytes + # left is sufficient to download this shard. Otherwise, we have to keep downloading anyways. + bytes_sufficient = True + if not current_batch_downloads: + if download_bytes_left is not None: + bytes_sufficient = (download_size <= download_bytes_left) + else: + raise ValueError("Must specify download_bytes_left if not downloading for \ + current batch.") + + if download_shard not in node.shards and bytes_sufficient: + # Shard is not present in node, so we download it. + # Handle possible shard eviction. + if cache_limit and node.cache_usage + shard_raw_size > cache_limit: + # Evict shards until we have space for this shard. + node.evict_until_satisfied(shard_raw_size, raw_shard_sizes) + + # Shards are assumed to be decompressed on download, so cache_usage increases by raw size. + node.cache_usage += shard_raw_size + + # If we are downloading shards for the current batch, we need to check if the shard + # is needed by the current batch. If it is, we make sure to mark it as most recently used. + # If we are not downloading shards for the current batch then no shard is marked as used. + if current_batch_downloads: + if shards_needed is not None: + node.add_shard(download_shard) + shards_needed.discard(download_shard) + else: + raise ValueError("Must specify shards_needed if downloading for current batch.") + else: + node.add_shard(download_shard) + + if shard_access_starts[download_shard] == -1: + # Shard has never been accessed before. Set its access start. + shard_access_starts[download_shard] = step_num + # For any shard access, we are accessing the shard so we need the shard until + # at least the next step begins. Adding 0.5 ensures that we evict shards + # after they are used for the last time, but before they are replaced by + # new downloads in the next step. + shard_access_ends[download_shard] = step_num + 0.5 + + # Advance the worker download index. + node.increment_worker_download_index() + # We have now downloaded this shard. Remove from worker download queue. + worker_download.pop() + return ("downloaded", download_size) + elif not(current_batch_downloads) and download_shard not in node.shards: + # This is the case when we are not downloading for the current batch, and need to download + # a shard but do not have the download bytes to fully download the shard this step. + # We do not advance the worker download index since we still are downloading this shard. + node.partial_shard_bytes = download_bytes_left + # We only account for downloaded bytes when we fully download a shard. + return ("partial", 0) + else: + # The shard is already present in the node. Advance the worker download index. + node.increment_worker_download_index() + # Node already has this shard. Remove from worker download queue. + worker_download.pop() + return ("present", 0) + +def run_cache_limit(shard_access_starts: NDArray, + shard_access_ends: NDArray, + raw_shard_sizes: NDArray, + nodes: list[NodeTracker]) -> int: + + # Find the overall needed cache usage, as the max needed for any node at any point. + needed_cache_usage = 0 + for node in nodes: + # For each node, create its own list of shard access events. + # Access event tuples are (event time, shard idx, event type) + # Event types: 0 means a shard has been accessed, 1 means a shard has ended access. + node_shards = node.get_all_shards() + access_events = [] + access_events += [(shard_access_starts[i], i, 0) for i in node_shards] + access_events += [(shard_access_ends[i], i, 1) for i in node_shards] + + # Sort the access events to get shard events, in order. + access_events.sort(key=lambda x: x[0]) + + # For each shard event, update the cache usage. Assume that shards are decompressed + # immediately on download, so use raw shard sizes. + curr_cache_usage = 0 + for event in access_events: + if event[2] == 0: + # Shard has been accessed. Increment cache usage. + curr_cache_usage += raw_shard_sizes[event[1]] + # Needed cache usage is the max of all cache usages across the run. + if curr_cache_usage > needed_cache_usage: + needed_cache_usage = curr_cache_usage + else: + # Shard access has ended. Decrement cache usage. + curr_cache_usage -= raw_shard_sizes[event[1]] + + return needed_cache_usage \ No newline at end of file diff --git a/simulator/core/shuffle_quality.py b/simulator/core/shuffle_quality.py new file mode 100644 index 000000000..6af4ff1c4 --- /dev/null +++ b/simulator/core/shuffle_quality.py @@ -0,0 +1,201 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Determine shuffle quality of a run over a fixed number of samples.""" + +import numpy as np +from streaming.base.partition.orig import get_partitions_orig +from streaming.base.shuffle import get_shuffle +import matplotlib.pyplot as plt +from numpy.typing import NDArray +from core.utils import remove_padded_samples +import math +import os +import time + +def get_entropy(ordering): + # get differences between elements + diffs = np.diff(ordering) + # diffs = np.insert(diffs, ordering.shape[0]-1, ordering[0]-ordering[-1]) + + # change negative differences to positive + diffs = np.abs(diffs) + + # count frequencies of differences + diff_freqs = np.bincount(diffs) + + # remove zero frequency elements d + diff_freqs = diff_freqs[diff_freqs != 0] + + # convert frequencies to probabilities + diff_probs = diff_freqs / (ordering.shape[0]-1) + + # calculate entropy + diff_entropy = -np.sum(diff_probs*np.log2(diff_probs)) + + return diff_entropy + +def get_partition_shard_info(epoch_size: int, + canonical_nodes: int, + physical_nodes: int, + devices: int, + workers: int, + device_batch_size: int, + samples_per_shard: int) -> tuple[NDArray, NDArray, NDArray]: + """Get a partition for a shuffle. + + Args: + epoch_size (int): The number of samples in an epoch. + canonical_nodes (int): The number of canonical nodes. + physical_nodes (int): The number of physical nodes. + devices (int): The number of devices. + workers (int): The number of workers. + device_batch_size (int): The batch size per device. + samples_per_shard (int): Average number of samples per shard. + + Returns: + tuple[NDArray, NDArray, NDArray]: The partition, in order, the + sizes of each shard, and the mapping of sample id to shard id. + """ + + num_samples = epoch_size + if num_samples > 100000000: + print("Epoch size is >100 million. Using 100 million samples for shuffle quality analysis.") + num_samples = 100000000 + + partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, + devices, workers, device_batch_size) + partition = partition.transpose(3, 2, 0, 1, 4).flatten() + partition = remove_padded_samples(partition) + + # Construct shard sizes array. + num_shards = num_samples // samples_per_shard + shard_sizes = np.array([samples_per_shard]*num_shards) + if num_samples % samples_per_shard != 0: + num_shards += 1 + shard_sizes = np.append(shard_sizes, num_samples % samples_per_shard) + + # Construct sample id -> shard id mapping. + shard_per_sample = np.repeat(np.arange(num_shards-1), samples_per_shard) + remaining_samples = num_samples - len(shard_per_sample) + shard_per_sample = np.append(shard_per_sample, np.full(remaining_samples, num_shards-1)) + + return partition, shard_sizes, shard_per_sample + +def get_entropy_shuffle_quality(shuffle_algo: str, + partition: NDArray, + shard_sizes: NDArray, + shard_per_sample: NDArray, + canonical_nodes: int, + seed: int, + shuffle_block_size: int) -> float: + """Evaluate the entropy of a shuffle algorithm. + + Args: + shuffle_algo (str): The shuffle algorithm to use. + partition (NDArray): The flattened, in-order partition to use. + shard_sizes (NDArray): The sizes of each shard. + shard_per_sample (NDArray): The mapping of sample id to shard id. + canonical_nodes (int): The number of canonical nodes. + seed (int): The seed to use for the shuffle. + shuffle_block_size (int): The shuffle block size. + + Returns: + float: The entropy of the shuffle for the first NCN*SBS samples. + """ + + if shuffle_algo != 'none': + # Assume we are shuffling only for epoch 0. + shuffle_ordering = get_shuffle(shuffle_algo, shard_sizes, canonical_nodes, + seed, 0, shuffle_block_size) + partition = shuffle_ordering[partition] + sample_entropy = get_entropy(partition) + shard_entropy = get_entropy(shard_per_sample[partition]) + return sample_entropy + shard_entropy + +def analyze_all_shuffle_quality(algos: list[str], + canonical_nodes: int, + physical_nodes: int, + devices: int, + workers: int, + device_batch_size: int, + shuffle_block_size: int, + samples_per_shard: int, + epoch_size: int, + seed: int): + """Analyze the quality of this shuffle across algorithms. + + Args: + algos (list[str]): The algorithms to analyze. + canonical_nodes (int): The number of canonical nodes. + physical_nodes (int): The number of physical nodes. + devices (int): The number of devices. + workers (int): The number of workers. + device_batch_size (int): The batch size per device. + shuffle_block_size (int): The shuffle block size. + samples_per_shard (int): Average number of samples per shard. + epoch_size (int): The number of samples in an epoch. + seed (int): The seed to use for the shuffle. + Returns: + list[tuple[str, float]]: Shuffle algorithms and shuffle qualities. + """ + + print("Analyzing shuffle quality...") + + shuffle_qualities = [] + + # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. + partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, + canonical_nodes, + physical_nodes, + devices, workers, + device_batch_size, + samples_per_shard) + for algo in algos: + shuffle_qualities.append(get_entropy_shuffle_quality(algo, partition, shard_sizes, + shard_per_sample, canonical_nodes, + seed, shuffle_block_size)) + + return shuffle_qualities + +def analyze_shuffle_quality(algo: str, + canonical_nodes: int, + physical_nodes: int, + devices: int, + workers: int, + device_batch_size: int, + shuffle_block_size: int, + samples_per_shard: int, + epoch_size: int, + seed: int): + """Analyze the quality of a shuffle for one algorithm. + + Args: + algo (str): The algorithm to analyze. + canonical_nodes (int): The number of canonical nodes. + physical_nodes (int): The number of physical nodes. + devices (int): The number of devices. + workers (int): The number of workers. + device_batch_size (int): The batch size per device. + shuffle_block_size (int): The shuffle block size. + samples_per_shard (int): Average number of samples per shard. + epoch_size (int): The number of samples in an epoch. + seed (int): The seed to use for the shuffle. + Returns: + tuple[str, float]: Shuffle algorithm and shuffle quality. + """ + + print(f"Analyzing shuffle quality for {algo}...") + + # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. + partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, + canonical_nodes, + physical_nodes, + devices, workers, + device_batch_size, + samples_per_shard) + + shuffle_quality = get_entropy_shuffle_quality(algo, partition, shard_sizes, shard_per_sample, + canonical_nodes, seed, shuffle_block_size) + + return algo, shuffle_quality diff --git a/simulator/core/sim_time.py b/simulator/core/sim_time.py new file mode 100644 index 000000000..6c8e3ae06 --- /dev/null +++ b/simulator/core/sim_time.py @@ -0,0 +1,359 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""simulator time classes straight copied from MosaicML composer.""" + +from __future__ import annotations +import datetime +import re +from enum import Enum +from typing import Any, Dict, Generic, Optional, TypeVar, Union, cast + + +class TimeUnit(Enum): + """Enum class to represent units of time for the training process. + + Attributes: + EPOCH (str): Epochs. + BATCH (str): Batches (i.e. number of optimization steps) + SAMPLE (str): Samples. + TOKEN (str): Tokens. Applicable for natural language processing (NLP) models. + DURATION (str): Fraction of the training process complete, on ``[0.0, 1.0)`` + """ + EPOCH = 'ep' + BATCH = 'ba' + SAMPLE = 'sp' + TOKEN = 'tok' + DURATION = 'dur' + + +# regex for parsing time string, matches timeunit and chars prior to unit as value +_TIME_STR_REGEX = re.compile(r'^(.+)(' + r'|'.join([fr'{time_unit.value}' for time_unit in TimeUnit]) + r')$', + flags=re.IGNORECASE) + +TValue = TypeVar('TValue', int, float) + +class Time(Generic[TValue]): + """Time represents static durations of training time in terms of a :class:`TimeUnit` enum. + + See the :doc:`Time Guide ` for more details on tracking time during training. + + To construct an instance of :class:`Time`, you can either: + + #. Use a value followed by a :class:`TimeUnit` enum or string. For example, + + >>> Time(5, TimeUnit.EPOCH) # describes 5 epochs. + Time(5, TimeUnit.EPOCH) + >>> Time(30_000, "tok") # describes 30,000 tokens. + Time(30000, TimeUnit.TOKEN) + >>> Time(0.5, "dur") # describes 50% of the training process. + Time(0.5, TimeUnit.DURATION) + + #. Use one of the helper methods. See: + + - :meth:`Time.from_epoch` + - :meth:`Time.from_batch` + - :meth:`Time.from_sample` + - :meth:`Time.from_token` + - :meth:`Time.from_duration` + - :meth:`Time.from_timestring`. + + :class:`Time` supports addition and subtraction with other :class:`Time` instances that share the same + :class:`TimeUnit`. For example: + + >>> Time(1, TimeUnit.EPOCH) + Time(2, TimeUnit.EPOCH) + Time(3, TimeUnit.EPOCH) + + :class:`Time` supports multiplication. The multiplier must be either a number or have units of + :attr:`TimeUnit.DURATION`. The multiplicand is scaled, and its units are kept. + + >>> Time(2, TimeUnit.EPOCH) * 0.5 + Time(1, TimeUnit.EPOCH) + + >>> Time(2, TimeUnit.EPOCH) * Time(0.5, TimeUnit.DURATION) + Time(1, TimeUnit.EPOCH) + + + :class:`Time` supports division. If the divisor is an instance of :class:`Time`, then it + must have the same units as the dividend, and the result has units of :attr:`TimeUnit.DURATION`. + For example: + + >>> Time(4, TimeUnit.EPOCH) / Time(2, TimeUnit.EPOCH) + Time(2.0, TimeUnit.DURATION) + + If the divisor is number, then the dividend is scaled, and it keeps its units. For example: + + >>> Time(4, TimeUnit.EPOCH) / 2 + Time(2, TimeUnit.EPOCH) + + Args: + value (int | float): The amount of time. + unit (str | TimeUnit): The :class:`TimeUnit` for ``value``. + """ + + def __init__( + self, + value: TValue, + unit: Union[str, TimeUnit], + ): + unit = TimeUnit(unit) + if unit == TimeUnit.DURATION: + value = cast(TValue, float(value)) + else: + if not isinstance(value, int): + raise TypeError(f'value {value} is of type {type(value)}. Units {unit} require integer values.') + self._value, self._unit = value, TimeUnit(unit) + + @classmethod + def from_epoch(cls, epoch: int) -> Time: + """Create a :class:`Time` with units of :attr:`TimeUnit.EPOCH`. + + Equivalent to ``Time(epoch, TimeUnit.EPOCH)``. + + Args: + epoch (int): Number of epochs. + + Returns: + Time: :class:`Time` instance, in epochs. + """ + return cls(epoch, TimeUnit.EPOCH) + + @classmethod + def from_batch(cls, batch: int) -> Time: + """Create a :class:`Time` with units of :attr:`TimeUnit.BATCH`. + + Equivalent to ``Time(batch, TimeUnit.BATCH)``. + + Args: + batch (int): Number of batches. + + Returns: + Time: :class:`Time` instance, in batches. + """ + return cls(batch, TimeUnit.BATCH) + + @classmethod + def from_sample(cls, sample: int) -> Time: + """Create a :class:`Time` with units of :attr:`TimeUnit.SAMPLE`. + + Equivalent to ``Time(sample, TimeUnit.SAMPLE)``. + + Args: + sample (int): Number of samples. + + Returns: + Time: :class:`Time` instance, in samples. + """ + return cls(sample, TimeUnit.SAMPLE) + + @classmethod + def from_token(cls, token: int) -> Time: + """Create a :class:`Time` with units of :attr:`TimeUnit.TOKEN`. + + Equivalent to ``Time(sample, TimeUnit.TOKEN)``. + + Args: + token (int): Number of tokens. + + Returns: + Time: :class:`Time` instance, in tokens. + """ + return cls(token, TimeUnit.TOKEN) + + @classmethod + def from_duration(cls, duration: float) -> Time: + """Create a :class:`Time` with units of :attr:`TimeUnit.DURATION`. + + Equivalent to ``Time(duration, TimeUnit.DURATION)``. + + Args: + duration (float): Duration of the training process. Should be on ``[0, 1)`` + where ``0`` represents the beginning of the training process and ``1`` + represents a completed training process. + + Returns: + Time: :class:`Time` instance, in duration. + """ + return cls(duration, TimeUnit.DURATION) + + @property + def value(self) -> TValue: + """The value of the time, as a number.""" + return self._value + + @property + def unit(self) -> TimeUnit: + """The unit of the time.""" + return self._unit + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.value}, {self.unit})' + + def __str__(self) -> str: + return f'{self.value}{self.unit.value}' + + def to_timestring(self): + """Get the time-string representation. + + For example: + + >>> Time(5, TimeUnit.EPOCH).to_timestring() + '5ep' + + Returns: + str: The time-string representation. + """ + return str(self) + + def _parse(self, other: object) -> Time: + # parse ``other`` into a Time object + if isinstance(other, Time): + return other + if isinstance(other, int): + return Time(other, self.unit) + if isinstance(other, str): + other_parsed = Time.from_timestring(other) + return other_parsed + + raise TypeError(f'Cannot convert type {other} to {self.__class__.__name__}') + + def _cmp(self, other: Union[int, float, Time, str]) -> int: + # When doing comparisions, and other is an integer (or float), we can safely infer + # the unit from self.unit + # E.g. calls like this should be allowed: if batch < 42: do_something() + # This eliminates the need to call .value everywhere + if not isinstance(other, (int, float, Time, str)): + return NotImplemented + if isinstance(other, (int, float)): + other = type(self)(other, self.unit) + other = self._parse(other) + if self.unit != other.unit: + raise RuntimeError(f'Cannot compare {self} to {other} since they have different units.') + if self.value < other.value: + return -1 + if self.value == other.value: + return 0 + assert self.value > other.value + return 1 + + def __eq__(self, other: Union[int, float, Time, str]): + return self._cmp(other) == 0 + + def __ne__(self, other: Union[int, float, Time, str]): + return self._cmp(other) != 0 + + def __lt__(self, other: Union[int, float, Time, str]): + return self._cmp(other) < 0 + + def __le__(self, other: Union[int, float, Time, str]): + return self._cmp(other) <= 0 + + def __gt__(self, other: Union[int, float, Time, str]): + return self._cmp(other) > 0 + + def __ge__(self, other: Union[int, float, Time, str]): + return self._cmp(other) >= 0 + + def __add__(self, other: Union[int, float, Time, str]) -> Time[TValue]: + other = self._parse(other) + if self.unit != other.unit: + raise RuntimeError(f'Cannot add {self} to {other} since they have different units.') + return Time(self.value + other.value, self.unit) + + def __radd__(self, other: Union[int, float, Time, str]) -> Time[TValue]: + return self + other + + def __sub__(self, other: Union[int, float, Time, str]) -> Time[TValue]: + other = self._parse(other) + if self.unit != other.unit: + raise RuntimeError(f'Cannot subtract {other} from {self} since they have different units.') + return Time(self.value - other.value, self.unit) + + def __rsub__(self, other: Union[int, float, Time, str]) -> Time[TValue]: + return (-self) + other + + def __neg__(self) -> Time[TValue]: + return Time(cast(TValue, -self.value), self.unit) + + def __pos__(self) -> Time[TValue]: + return Time(self.value, self.unit) + + def __int__(self): + return int(self.value) + + def __float__(self): + return float(self.value) + + def __truediv__(self, other: object) -> Time[float]: + if isinstance(other, (float, int)): + return Time(type(self.value)(self.value / other), self.unit) + other = self._parse(other) + if self.unit != other.unit: + raise RuntimeError(f'Cannot divide {self} by {other} since they have different units.') + return Time(self.value / other.value, TimeUnit.DURATION) + + def __mul__(self, other: object): + if isinstance(other, (float, int)): + # Scale by the value. + return Time(type(self.value)(self.value * other), self.unit) + other = self._parse(other) + if other.unit != TimeUnit.DURATION and self.unit != TimeUnit.DURATION: + raise RuntimeError(f'Multiplication is supported only if one of the units is Duration') + real_unit = self.unit if other.unit == TimeUnit.DURATION else other.unit + real_type = float if real_unit == TimeUnit.DURATION else int + return Time(real_type(self.value * other.value), real_unit) + + def __rmul__(self, other: object): + return self * other + + def __hash__(self): + return hash((self.value, self.unit)) + + @classmethod + def from_timestring(cls, timestring: str) -> Time: + """Parse a time string into a :class:`Time` instance. + + A time string is a numerical value followed by the value of a :class:`TimeUnit` enum. For example: + + >>> Time.from_timestring("5ep") # describes 5 epochs. + Time(5, TimeUnit.EPOCH) + >>> Time.from_timestring("3e4tok") # describes 30,000 tokens. + Time(30000, TimeUnit.TOKEN) + >>> Time.from_timestring("0.5dur") # describes 50% of the training process. + Time(0.5, TimeUnit.DURATION) + + Returns: + Time: An instance of :class:`Time`. + """ + match = _TIME_STR_REGEX.findall(timestring) + if len(match) != 1: + raise ValueError(f'Invalid time string: {timestring}') + match = match[0] + match = [x for x in match if x != ''] + assert len(match) == 2, 'each match should have a number followed by the key' + value = match[0] + unit = TimeUnit(match[1]) + value = float(value) # always parsing first as float b/c it could be scientific notation + if unit != TimeUnit.DURATION: + if int(value) != value: + raise TypeError(f'value {value} is not an integer. Units {unit} require integer values.') + value = int(value) + return cls(value, unit) + +def ensure_time(maybe_time: Union[Time, str, int], int_unit: Union[TimeUnit, str]) -> Time: + """Ensure ``maybe_time`` is an instance of :class:`.Time`. + + Args: + maybe_time (Time | str): A time string, integer, or instance of :class:`.Time`. + int_unit (TimeUnit | str): The unit to use if ``maybe_time`` is an integer + + Returns: + Time: An instance of :class:`.Time`. + """ + if isinstance(maybe_time, str): + return Time.from_timestring(maybe_time) + if isinstance(maybe_time, int): + return Time(maybe_time, int_unit) + if isinstance(maybe_time, Time): + return maybe_time + raise TypeError(f'Unsupported type for ensure_time: {type(maybe_time)}') \ No newline at end of file diff --git a/simulator/core/simulation_dataset.py b/simulator/core/simulation_dataset.py new file mode 100644 index 000000000..ab71bed4e --- /dev/null +++ b/simulator/core/simulation_dataset.py @@ -0,0 +1,520 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from streaming.base import StreamingDataset, Stream +from streaming.base.spanner import Spanner +from streaming.base.format import get_index_basename +from streaming.base.util import bytes_to_int, number_abbrev_to_int +from streaming.base.batching import generate_work +from typing import Optional, Sequence, Union, Tuple +from core.simulation_world import SimulationWorld +from core.simulation_spanner import SimulationSpanner +import warnings +import numpy as np +from numpy.typing import NDArray +from math import ceil +import time +import os +import shutil + + +class SimulationDataset(StreamingDataset): + """Near replica of StreamingDataset for simulation purposes. + + Args: + streams (Sequence[Stream], optional): One or more streams to stream/cache samples from, + which may be upsampled or downsampled. StreamingDataset uses either ``streams`` or + ``remote``/``local``. Defaults to ``None``. + remote (str, optional): Remote path or directory to download the dataset from. If ``None``, + its data must exist locally. StreamingDataset uses either ``streams`` or + ``remote``/``local``. Defaults to ``None``. + local (str, optional): Local working directory to download shards to. This is where shards + are cached while they are being used. Uses a temp directory if not set. + StreamingDataset uses either ``streams`` or ``remote``/``local``. Defaults to ``None``. + split (str, optional): Which dataset split to use, if any. If provided, we stream from/to + the ``split`` subdirs of ``remote`` and ``local``. Defaults to ``None``. + download_retry (int): Number of download re-attempts before giving up. Defaults to ``2``. + download_timeout (float): Number of seconds to wait for a shard to download before raising + an exception. Defaults to ``60``. + validate_hash (str, optional): Optional hash or checksum algorithm to use to validate + shards. Defaults to ``None``. + keep_zip (bool): Whether to keep or delete the compressed form when decompressing + downloaded shards. If ``False``, keep iff remote is local or no remote. Defaults to + ``False``. + epoch_size (Union[int, str], optional): Number of samples to draw per epoch balanced across all + streams. If ``None``, takes its value from the total number of underlying samples. + Provide this field if you are weighting streams relatively to target a larger or + smaller epoch size. Defaults to ``None``. Can also take in human-readable number + abbreviations (e.g., ``"100k"``, ``"64M"``, ``"77b"``, and so on). Defaults to ``None``. + predownload (int, optional): Target number of samples to download per worker in advance + of current sample. Workers will attempt to download ahead by this many samples during, + but not before, training. Recommendation is to provide a value greater than per device + batch size to ensure at-least per device batch size number of samples cached locally. + If ``None``, its value gets derived using per device batch size and number of + canonical nodes ``max(batch_size, 256 * batch_size // num_canonical_nodes)``. + Defaults to ``None``. + cache_limit (Union[int, str], optional): Maximum size in bytes of this StreamingDataset's + shard cache. Before downloading a shard, the least recently used resident shard(s) + may be evicted (deleted from the local cache) in order to stay under the limit. + Set to ``None`` to disable shard eviction. Supports integer bytes as well as string + human-readable bytes (e.g., ``100b``, ``64kb``, ``77mb``, and so on). Defaults to + ``None``. + partition_algo (str): Which partitioning algorithm to use. Defaults to ``orig``. + num_canonical_nodes (int, optional): Canonical number of nodes for shuffling with + resumption. The sample space is divided evenly according to the number of canonical + nodes. The higher the value, the more independent non-overlapping paths the + StreamingDataset replicas take through the shards per model replica (increasing data + source diversity). Defaults to ``None``, which is interpreted as 64 times the number + of nodes of the initial run. + + .. note:: + + For sequential sample ordering, set ``shuffle`` to ``False`` and + ``num_canonical_nodes`` to the number of physical nodes of the initial run. + batch_size (int, optional): Batch size of its DataLoader, which affects how the dataset is + partitioned over the workers. Defaults to ``None``. + shuffle (bool): Whether to iterate over the samples in randomized order. Defaults to + ``False``. + shuffle_algo (str): Which shuffling algorithm to use. Defaults to ``py1s``. + shuffle_seed (int): Seed for Deterministic data shuffling. Defaults to ``9176``. + shuffle_block_size (int): Unit of shuffle. Defaults to ``1 << 18``. + sampling_method (str): Which sampling method to use, either ``balanced`` or ``fixed``. + Defaults to ``balanced``. + """ + + def __init__(self, + nodes: int, + devices: int, + workers: int, + streams: Optional[Sequence[Stream]] = None, + remote: Optional[str] = None, + local: Optional[str] = None, + split: Optional[str] = None, + download_retry: int = 2, + download_timeout: float = 60, + validate_hash: Optional[str] = None, + keep_zip: bool = False, + epoch_size: Optional[Union[int, str]] = None, + predownload: Optional[int] = None, + cache_limit: Optional[Union[int, str]] = None, + partition_algo: str = 'orig', + num_canonical_nodes: Optional[int] = None, + batch_size: Optional[int] = None, + shuffle: bool = False, + shuffle_algo: str = 'py1s', + shuffle_seed: int = 9176, + shuffle_block_size: int = 1 << 18, + sampling_method: str = 'balanced', + sampling_granularity: int = 1, + batching_method: str = 'random') -> None: + + # Time how long it takes for StreamingDataset instantiation + t0 = time.time() + + # Global arguments (which do not live in Streams). + self.nodes = nodes + self.devices = devices + self.workers = workers + self.cache_limit = cache_limit + self.partition_algo = partition_algo + self.num_canonical_nodes = num_canonical_nodes or 64*nodes + self.batch_size = batch_size or 1 + self.predownload = predownload if predownload is not None \ + else max(self.batch_size, 256 * self.batch_size // self.num_canonical_nodes) + self.shuffle = shuffle + self.shuffle_algo = shuffle_algo + self.shuffle_seed = shuffle_seed + self.shuffle_block_size = shuffle_block_size + self.sampling_method = sampling_method + self.sampling_granularity = sampling_granularity + self.batching_method = batching_method + + # Check streams vs remote/local. + if bool(streams) == (bool(remote) or bool(local)): + raise ValueError( + 'You must provide either `streams` or `remote`/`local`, but not both.') + + # Check sampling method is one of "balanced" or "fixed". + if self.sampling_method not in ['balanced', 'fixed']: + raise ValueError( + f'Invalid sampling method: {sampling_method}. Must be one of `balanced` or `fixed`.' + ) + + # Check sampling method is one of "balanced" or "fixed". + if self.batching_method not in ['random', 'per_stream', 'stratified']: + raise ValueError( + f'Invalid batching method: {batching_method}. Must be one of `random`, \ + `per_stream`, or `stratified`.' + ) + + # Check that predownload is at least per device batch size. + if self.predownload is not None and self.batch_size is not None and \ + self.predownload < self.batch_size: + warnings.warn(f'predownload < batch_size ({self.predownload} < {self.batch_size}).' + + f'This may result in slower batch time. Recommendation is to set ' + + f'predownload to at-least batch_size.') + # Convert epoch size from string to int, if needed. Cannot be negative. + epoch_size_value = None + if epoch_size: + epoch_size_value = number_abbrev_to_int(epoch_size) + if epoch_size_value < 0: + raise ValueError(f'Epoch size cannot be negative. Received {epoch_size_value}.') + + + # Initialize the Stream defaults and normalize to a list of Streams. + if streams: + default = { + 'remote': remote, + 'local': local, + 'split': split, + 'download_retry': download_retry, + 'download_timeout': download_timeout, + 'validate_hash': validate_hash, + 'keep_zip': keep_zip, + } + for stream in streams: + stream.apply_default(default) + else: + default = Stream(remote=remote, + local=local, + split=split, + download_retry=download_retry, + download_timeout=download_timeout, + validate_hash=validate_hash, + keep_zip=keep_zip) + streams = [default] + + # Validate the stream weighting scheme (relative or absolute) to catch errors before we go + # to the trouble of loading them. + Stream.validate_weights(streams) + + # Set streams. + self.streams = streams + self.num_streams = len(streams) + + self.stream_info = {} + # 0 means index file is remote, 1 means local, 2 means created + indices_created = [] + for stream_idx, stream in enumerate(self.streams): + if stream.remote: + filepath = os.path.join(stream.remote, stream.split, get_index_basename()) + indices_created.append(0) + else: + filepath = os.path.join(stream.local, stream.split, get_index_basename()) + # This suffix means a mock index file was created. Have to clean up later. + if stream.local.split('_')[-1] == "indexcreated": + indices_created.append(2) + else: + # Index file is local. Don't delete later. + indices_created.append(1) + self.stream_info[stream_idx] = {"path": filepath, + "local": stream.local, + "remote": stream.remote, + "proportion": stream._proportion, + "repeat": stream._repeat, + "choose": stream._choose} + + # Initialize the SimulationWorld, which tells us about nodes/devices/workers + self.world = SimulationWorld(self.nodes, self.devices, self.workers) + + # Download each stream's index, load their shards, and map streams <-> shards. + self.num_samples = 0 + self.shards = [] + stream_per_shard = [] + self.shard_offset_per_stream = np.zeros(self.num_streams, np.int64) + self.shards_per_stream = np.zeros(self.num_streams, np.int64) + self.sample_offset_per_stream = np.zeros(self.num_streams, np.int64) + self.samples_per_stream = np.zeros(self.num_streams, np.int64) + index_filenames = [] + local_foldernames = [] + for stream_id, stream in enumerate(self.streams): + print("Processing index file for stream", stream_id+1) + stream_shards = stream.get_shards(self.world) + num_stream_samples = sum(map(len, stream_shards)) + index_filename = os.path.join(stream.local, stream.split, get_index_basename()) + index_filenames.append(index_filename) + local_foldernames.append(stream.local) + if not num_stream_samples: + raise RuntimeError(f'Stream contains no samples: {index_filename}.') + stream_per_shard += [stream_id] * len(stream_shards) + self.shard_offset_per_stream[stream_id] = len(self.shards) + self.shards_per_stream[stream_id] = len(stream_shards) + self.sample_offset_per_stream[stream_id] = self.num_samples + self.samples_per_stream[stream_id] = num_stream_samples + self.shards += stream_shards + self.num_samples += num_stream_samples + + self.stream_per_shard = np.array(stream_per_shard, np.int64) + self.num_shards = len(self.shards) + + # Check that cache limit is possible. + if self.cache_limit: + if isinstance(self.cache_limit, str): + self.cache_limit = bytes_to_int(self.cache_limit) + min_cache_usage = sum((stream.get_index_size() for stream in streams)) + if self.cache_limit <= min_cache_usage: + raise ValueError(f'Minimum cache usage ({min_cache_usage} bytes) is larger than ' + + f'the cache limit ({self.cache_limit} bytes). Please raise ' + + f'`cache_limit`.') + + for stream_idx, index_filename in enumerate(index_filenames): + if indices_created[stream_idx] == 0: + # Index file was downloaded from remote. + try: + # Remove the index.json file. + os.remove(index_filename) + except FileNotFoundError: + pass + elif indices_created[stream_idx] == 1: + # Index file was local. Don't delete. + pass + else: + # Directory and index file were created. Delete both. + shutil.rmtree(local_foldernames[stream_idx]) + + + # Build the shard index (for partitioning and mapping samples to shards). + self.samples_per_shard = np.array([shard.samples for shard in self.shards], np.int64) + self.sample_offset_per_shard = self.samples_per_shard.cumsum() - self.samples_per_shard + self.spanner = SimulationSpanner(self.samples_per_shard) + + # Also keep track of the raw and compressed sizes of each shard, indexed by shard_id. + self.raw_shard_sizes = np.array([shard.get_raw_size() for shard in self.shards]) + self.zip_shard_sizes = np.array([shard.get_zip_size() or 0 for shard in self.shards]) + + print("Total number of shards:", self.num_shards) + print("Average number of samples per shard:", self.num_samples/self.num_shards) + print("Average raw shard size (bytes):", np.mean(self.raw_shard_sizes)) + print("Average zip shard size (bytes):", np.mean(self.zip_shard_sizes)) + + # Now that we know the number of underlying samples of each stream, derive each stream's + # true proportion/repeat/choose, as well as the total epoch size. + self.epoch_size = Stream.apply_weights(self.streams, self.samples_per_stream, + epoch_size_value, self.shuffle_seed) + + # Length (__len__) is the resampled epoch size divided over the number of devices. + self.length = ceil(self.epoch_size / self.world.num_ranks) + + t1 = time.time() + self.instantiation_time = t1-t0 + + print("SimulationDataset created successfully.") + + + def get_sample_partition(self, epoch, sample_in_epoch) -> NDArray: + """Get the dataset's partition of this epoch's sample space. + + Args: + epoch (int): Which epoch it is. + sample_in_epoch (int): Where we are in the epoch. + + Returns: + NDArray[np.int64]: Our partition of the epoch. + """ + return generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) + + def get_samples_per_node(self, epoch, sample_in_epoch) -> NDArray: + """Get the dataset's number of samples per node, worker, device. + + Args: + epoch (int): Which epoch it is. + sample_in_epoch (int): Where we are in the epoch. + + Returns: + NDArray[np.int64]: The dataset's number of samples per node, worker, device. + """ + partition = generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) + # Modify partition to be in traversal order, per node, device, and worker. + return partition.reshape(self.nodes, self.devices, self.workers, -1) + + def get_spanner(self) -> Spanner: + """Get the dataset's spanner object, which does global sample id indexing. + + Returns: + Spanner: The dataset's spanner object. + """ + return self.spanner + + def get_raw_shard_sizes(self) -> NDArray: + """Get the dataset's raw shard sizes. + + Returns: + NDArray[np.int64]: The dataset's raw shard sizes. + """ + return self.raw_shard_sizes + + def get_zip_shard_sizes(self) -> NDArray: + """Get the dataset's zip shard sizes. + + Returns: + NDArray[np.int64]: The dataset's zip shard sizes. + """ + return self.zip_shard_sizes + + def get_nodes(self) -> int: + """Get the dataset's number of nodes. + + Returns: + int: The dataset's number of nodes. + """ + return self.nodes + + def get_devices(self) -> int: + """Get the dataset's number of devices. + + Returns: + int: The dataset's number of devices. + """ + return self.devices + + def get_workers(self) -> int: + """Get the dataset's number of workers. + + Returns: + int: The dataset's number of workers. + """ + return self.workers + + def get_num_canonical_nodes(self) -> int: + """Get the dataset's number of canonical nodes. + + Returns: + int: The dataset's number of canonical nodes. + """ + return self.num_canonical_nodes + + def get_batch_size(self) -> int: + """Get the dataset's batch size. + + Returns: + int: The dataset's batch size. + """ + return self.batch_size + + def get_num_shards(self) -> int: + """Get the dataset's number of shards. + + Returns: + int: The dataset's number of shards. + """ + return self.num_shards + + def get_avg_samples_per_shard(self) -> int: + """Get the dataset's average number of samples per shard. + + Returns: + int: The dataset's average number of samples per shard. + """ + return round(self.num_samples / self.num_shards) + + def get_predownload(self) -> int: + """Get the dataset's predownload. + + Returns: + int: The dataset's predownload. + """ + return self.predownload + + def get_cache_limit(self) -> Optional[int]: + """Get the dataset's cache limit. + + Returns: + int: The dataset's cache limit. + """ + return self.cache_limit + + def get_instantiation_time(self) -> float: + """Get the dataset's instantiation time. + + Returns: + float: The dataset's instantiation time. + """ + return self.instantiation_time + + def get_num_batches(self) -> int: + """Get the dataset's number of batches. + + Returns: + int: The dataset's number of batches. + """ + return self.epoch_size // (self.batch_size * self.devices * self.nodes) + + def get_stream_info(self) -> dict: + """Get the dataset's stream info. + + Returns: + dict: The dataset's stream info. + """ + return self.stream_info + + def get_shuffle(self) -> bool: + """Get the dataset's shuffle. + + Returns: + bool: The dataset's shuffle. + """ + return self.shuffle + + def get_shuffle_algo(self) -> str: + """Get the dataset's shuffle algorithm. + + Returns: + str: The dataset's shuffle algorithm. + """ + return self.shuffle_algo + + def get_shuffle_seed(self) -> int: + """Get the dataset's shuffle seed. + + Returns: + int: The dataset's shuffle seed. + """ + return self.shuffle_seed + + def get_shuffle_block_size(self) -> int: + """Get the dataset's shuffle block size. + + Returns: + int: The dataset's shuffle block size. + """ + return self.shuffle_block_size + + def get_epoch_size(self) -> int: + """Get the dataset's epoch size. + + Returns: + int: The dataset's epoch size. + """ + return self.epoch_size + + def get_sampling_method(self) -> str: + """Get the dataset's sampling method. + + Returns: + str: The dataset's sampling method. + """ + return self.sampling_method + + def get_sampling_granularity(self) -> int: + """Get the dataset's sampling granularity. + + Returns: + int: The dataset's sampling granularity. + """ + return self.sampling_granularity + + def get_batching_method(self) -> str: + """Get the dataset's batching method. + + Returns: + str: The dataset's batching method. + """ + return self.batching_method + + + + diff --git a/simulator/core/simulation_spanner.py b/simulator/core/simulation_spanner.py new file mode 100644 index 000000000..86b2bf8b3 --- /dev/null +++ b/simulator/core/simulation_spanner.py @@ -0,0 +1,66 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Mapping of global sample index to shard and relative sample index.""" +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from typing import Tuple +from streaming.base.spanner import Spanner + +import numpy as np +from numpy.typing import NDArray + + +class SimulationSpanner(Spanner): + """Given a list of shards, construct a mapping of global index to shard index. + + Args: + shard_sizes (NDArray[np.int64]): Number of samples in each shard. + span_size (int): Size of the divisions of the sample space. Defaults to ``1 << 10``. + """ + + def __init__(self, shard_sizes: NDArray[np.int64], span_size: int = 1 << 10) -> None: + self.shard_sizes = shard_sizes + self.span_size = span_size + self.num_samples = sum(shard_sizes) + self.shard_bounds = np.concatenate([np.zeros(1, np.int64), shard_sizes.cumsum()]) + + overflow = self.num_samples % span_size + underflow = span_size - overflow if overflow else 0 + self.shard_sizes[-1] += underflow + + sample_shards = np.repeat(np.arange(len(shard_sizes)), self.shard_sizes) + sample_shards = sample_shards.reshape(-1, span_size) + span_lowest_shards = sample_shards.min(1) + span_highest_shards = sample_shards.max(1) + + self.spans = [] + for low, high in zip(span_lowest_shards, span_highest_shards): + shards = np.arange(low, high + 1) + self.spans.append(shards) + + self.shard_sizes[-1] -= underflow + + def __getitem__(self, index: int) -> Tuple[int, int]: + """Map global sample index to shard and relative sample index. + + Args: + index (int): Global sample index. + + Returns: + int: Shard index of sample. + """ + if not (0 <= index < self.num_samples): + raise ValueError(f'Invalid sample index `{index}`: 0 <= {index} < {self.num_samples}') + + span = index // self.span_size + for shard in self.spans[span]: + shard_start = self.shard_bounds[shard] + shard_stop = self.shard_bounds[shard + 1] + if shard_start <= index < shard_stop: + return shard + + raise RuntimeError('Internal error: shards were indexed incorrectly') diff --git a/simulator/core/simulation_world.py b/simulator/core/simulation_world.py new file mode 100644 index 000000000..84d1865ef --- /dev/null +++ b/simulator/core/simulation_world.py @@ -0,0 +1,56 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from streaming.base.world import World + +class SimulationWorld(World): + """Information about the nodes, ranks and workers of this run. + + Nodes are all assumed to contain the same number of devices (via local_world_size). + + Nodes: + - node / num_nodes + - is_multinode + + Ranks: + - rank / num_ranks + - rank_of_node / ranks_per_node + + Workers: + - worker / num_workers + - worker_of_node / workers_per_node + - worker_of_rank / workers_per_rank + - is_leader + - is_local_leader + """ + + def __init__(self, + nodes: int, + devices: int, + workers: int): + + # For simulation purposes, we take in the nodes, devices, and workers from the + # SimulationDataset, and assume we are always rank 0 and worker 0. + + self.rank = 0 + self.num_ranks = nodes*devices + self.ranks_per_node = devices + self.rank_of_node = self.rank % self.ranks_per_node + self.node = self.rank // self.ranks_per_node + self.num_nodes = self.num_ranks // self.ranks_per_node + self.is_multinode = 1 < self.num_nodes + + self.worker_of_rank = 0 + self.workers_per_rank = workers + + self.worker = self.rank * self.workers_per_rank + self.worker_of_rank + self.num_workers = self.num_ranks * self.workers_per_rank + self.worker_of_node = self.rank_of_node * self.workers_per_rank + self.worker_of_rank + self.workers_per_node = self.ranks_per_node * self.workers_per_rank + + self.is_leader = not self.worker + self.is_local_leader = not self.worker_of_node \ No newline at end of file diff --git a/simulator/core/utils.py b/simulator/core/utils.py new file mode 100644 index 000000000..904af96f3 --- /dev/null +++ b/simulator/core/utils.py @@ -0,0 +1,154 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Peripheral functions for simulation functionality.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from typing import Optional, Tuple +from core.sim_time import Time, TimeUnit +from core.simulation_dataset import SimulationDataset +import numpy as np +from numpy.typing import NDArray + +def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> Tuple[int, int, int]: + """Get batches per epoch, epochs, and total epochs from a Time object. + + Args: + dataset (SimulationDataset): The dataset being simulated. + max_duration (Time): The maximum duration, can be specified in yaml. + + Returns: + Tuple[int, int, int]: batches per epoch, epochs, and the total batches. + """ + # get epochs, batches_per_epoch, and total_batches from a Time obect + dataset_batches = dataset.get_num_batches() + batches_per_epoch = dataset_batches + epochs = 1 + total_batches = dataset_batches + if max_duration.unit == TimeUnit.EPOCH: + epochs = max_duration.value + batches_per_epoch = dataset_batches + total_batches = epochs * batches_per_epoch + elif max_duration.unit == TimeUnit.BATCH: + full_epochs = max_duration.value // dataset_batches + # check if there is a partial epoch we should fulfill + if max_duration.value % dataset_batches != 0: + full_epochs += 1 + # make sure we don't simulate past the duration set. + if max_duration.value < dataset_batches: + batches_per_epoch = max_duration.value + else: + batches_per_epoch = dataset_batches + total_batches = max_duration.value + else: + raise ValueError("Simulator currently only supports max_duration in epochs or batches.") + + return batches_per_epoch, epochs, total_batches + +def get_total_batches(dataset: SimulationDataset, max_duration: Time) -> int: + """Get total batches from a Time object. + + Args: + dataset (SimulationDataset): The dataset being simulated. + max_duration (Time): The maximum duration, can be specified in yaml. + + Returns: + int: The total batches. + """ + dataset_batches = dataset.get_num_batches() + total_batches = dataset_batches + if max_duration.unit == TimeUnit.EPOCH: + epochs = max_duration.value + batches_per_epoch = dataset_batches + total_batches = epochs * batches_per_epoch + elif max_duration.unit == TimeUnit.BATCH: + total_batches = max_duration.value + else: + raise ValueError("Simulator currently only supports max_duration in epochs or batches.") + + return total_batches + +def remove_padded_samples(samples: NDArray) -> NDArray: + """Remove padded samples from a batch. + + Args: + samples (NDArray): The samples to remove padded samples from. + + Returns: + NDArray: The samples with padded samples removed. + """ + return np.delete(samples, np.where(samples == -1)) + +def bytes_to_time(bytes: int, bandwidth: int) -> float: + """Convert bytes to time. + + Args: + bytes (int): The bytes to convert. + bandwidth (int): The bandwidth available. + + Returns: + float: The time it takes to transfer the bytes. + """ + return bytes / bandwidth + +def time_to_bytes(time: float, bandwidth: int) -> int: + """Convert time to bytes. + + Args: + time (float): The time to convert. + bandwidth (int): The bandwidth available. + + Returns: + int: The bytes transferred in the time. + """ + return int(time * bandwidth) + +def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray: + step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + batch_throughput_rolling_avg = np.concatenate((np.array([0] * (window-1)), batch_throughput_rolling_avg)) + + return batch_throughput_rolling_avg + +def get_simulation_stats(step_times, time_per_sample, device_batch_size): + """Gets simulation stats for web UI. + + Args: + step_times (NDArray): time per step, as calculated by simulation + time_per_sample (float): time to process one sample on one device (seconds) + device_batch_size (int): batch size per device + + Returns: + Tuple[float, float, float]: percent of download-limited steps, warmup time + """ + + # calculate percent of download-limited steps + min_step_time = time_per_sample * device_batch_size + all_throughput_drops = np.count_nonzero(step_times > (min_step_time)) + + epsilon = 1e-6 + + # calculate warmup time (time to first max possible rolling average throughput) within epsilon + max_throughput = 1 / min_step_time + rolling_avg_throughput = get_rolling_avg_throughput(step_times) + if np.max(rolling_avg_throughput) >= max_throughput - epsilon: + warmup_step = np.argmax(rolling_avg_throughput >= (max_throughput)) + 1 + warmup_time = np.sum(step_times[:warmup_step]) + else: + # we never hit the max possible throughput + warmup_step = rolling_avg_throughput.shape[0] + warmup_time = np.sum(step_times) + + # see if there are throughput drops after warmup so we can notify users + if warmup_step != rolling_avg_throughput.shape[0]: + # if we did hit the max throughput then we check for later drops + post_warmup_throughput_drops = np.count_nonzero(step_times[warmup_step:] > min_step_time) + else: + # since warmup was the whole time, there are no post-warmup throughput drops + post_warmup_throughput_drops = 0 + + return all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops \ No newline at end of file diff --git a/simulator/core/yaml_processing.py b/simulator/core/yaml_processing.py new file mode 100644 index 000000000..071622185 --- /dev/null +++ b/simulator/core/yaml_processing.py @@ -0,0 +1,171 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Ingest yaml and create SimulationDataset.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from omegaconf import ListConfig +from omegaconf import OmegaConf as om +from core.simulation_dataset import SimulationDataset +from typing import Optional +from core.sim_time import Time, TimeUnit, ensure_time +from streaming.base import Stream + + +def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None) -> tuple[Optional[int], int, Time, int, dict]: + """Create SimulationDataset from yaml file and other needed args. + + Args: + yaml_dict (Optional[dict]): yaml file, read in as a dictionary + filepath (Optional[str]): path to yaml file + + Returns: + tuple[Optional[int], int, Time, int, dict]: total_devices, workers, max_duration, global_batch_size, train_dataset from yaml + """ + config = None + # Read in the yaml file + if filepath is not None: + with open(filepath) as f: + config = om.load(f) + elif yaml_dict is not None: + config = om.create(yaml_dict) + else: + raise ValueError("Must specify either filepath or yaml_dict.") + + # Get the number of devices (GPUs) + if 'compute' in config: + total_devices = config['compute']['gpus'] + else: + total_devices = None + + workers = None + train_dataset = None + max_duration = None + global_batch_size = None + # Get the training and dataset params + if 'parameters' in config: + config = config['parameters'] + + # get global batch size + if 'global_train_batch_size' in config: + global_batch_size = config['global_train_batch_size'] + elif 'batch_size' in config: + global_batch_size = config['batch_size'] + + # get number of workers and training dataset params + if 'train_loader' in config: + train_loader = config['train_loader'] + if 'num_workers' in train_loader: + workers = train_loader['num_workers'] + else: + workers = 1 + if 'dataset' in train_loader: + train_dataset = train_loader['dataset'] + else: + raise ValueError("dataset must be specified in the yaml file.") + elif 'dataset' in config: + dataset = config['dataset'] + if 'train_dataset' in dataset: + train_dataset = dataset['train_dataset'] + if 'streaming_kwargs' in train_dataset: + # Merge streaming kwargs, if present, into train_dataset + train_dataset.update(train_dataset['streaming_kwargs']) + if 'dataloader_kwargs' in train_dataset and 'num_workers' in train_dataset['dataloader_kwargs']: + workers = train_dataset['dataloader_kwargs']['num_workers'] + else: + workers = 1 + else: + raise ValueError("train_dataset must be specified in the yaml file.") + else: + raise ValueError("train_loader or dataset must be specified in the yaml file.") + + # Get duration of training from config + if 'max_duration' in config: + max_duration = config['max_duration'] + elif 'trainer' in config and 'max_duration' in config['trainer']: + max_duration = config['trainer']['max_duration'] + else: + raise ValueError("max_duration must be specified in the yaml file.") + + # convert max_duration to epochs or batches. + max_duration = ensure_time(max_duration, TimeUnit.EPOCH) + time_unit = max_duration.unit + if time_unit != TimeUnit.EPOCH and time_unit != TimeUnit.BATCH: + raise ValueError("Simulator currently only supports max_duration in epochs or batches.") + + return total_devices, workers, max_duration, global_batch_size, train_dataset + +def create_simulation_dataset(nodes, devices, workers, global_batch_size, train_dataset) -> SimulationDataset: + """Create SimulationDataset from yaml file and other needed args. + + Args: + nodes (int): number of physical nodes + devices (int): number of devices per node + workers (int): number of workers per device + global_batch_size (int): global batch size (samples) + train_dataset (DictConfig): train_dataset parameters from yaml file + indices_created (bool): whether new indices for streams have already been created + + Returns: + SimulationDataset: simulation dataset + """ + streams = None + # Check for cases where local and remote may be lists and turn those into streams. + if 'local' in train_dataset and 'remote' in train_dataset: + if isinstance(train_dataset['local'], ListConfig) and isinstance(train_dataset['remote'], ListConfig): + if len(train_dataset['local']) != len(train_dataset['remote']): + raise ValueError("local and remote must be the same length in the yaml file.") + streams = [] + for local, remote in zip(train_dataset['local'], train_dataset['remote']): + streams.append(Stream(local=local, remote=remote, split=train_dataset['split'] if 'split' in train_dataset else None)) + del train_dataset['local'] + del train_dataset['remote'] + + # Don't re-retrieve streams if we have built it from local and remote lists. + if not isinstance(streams, list): + streams_dict = train_dataset.get('streams', None) + if streams_dict is not None: + streams = [] + streams_dict = om.to_object(streams_dict) + for _, stream in streams_dict.items(): + if "path" in stream: + del stream["path"] + # Create Stream object from each dictionary entry + streams.append(Stream(**stream)) + + remote = train_dataset.get('remote', None) + local = train_dataset.get('local', None) + split = train_dataset.get('split', None) + download_retry = train_dataset.get('download_retry', 2) + download_timeout = train_dataset.get('download_timeout', 60) + validate_hash = train_dataset.get('validate_hash', None) + keep_zip = train_dataset.get('keep_zip', False) + epoch_size = train_dataset.get('epoch_size', None) + predownload = train_dataset.get('predownload', None) + cache_limit = train_dataset.get('cache_limit', None) + partition_algo = train_dataset.get('partition_algo', 'orig') + num_canonical_nodes = train_dataset.get('num_canonical_nodes', None) + if global_batch_size % (devices*nodes) != 0: + raise ValueError("global_batch_size must be divisible by total number of devices.") + batch_size = global_batch_size // (devices*nodes) + shuffle = train_dataset.get('shuffle', False) + shuffle_algo = train_dataset.get('shuffle_algo', 'py1s') + shuffle_seed = train_dataset.get('shuffle_seed', 9176) + shuffle_block_size = train_dataset.get('shuffle_block_size', 1 << 18) + sampling_method = train_dataset.get('sampling_method', 'balanced') + sampling_granularity = train_dataset.get('sampling_granularity', 1) + batching_method = train_dataset.get('batching_method', 'random') + + dataset = SimulationDataset(nodes, devices, workers, streams, remote, local, split, + download_retry, download_timeout, validate_hash, keep_zip, + epoch_size, predownload, cache_limit, partition_algo, + num_canonical_nodes, batch_size, shuffle, shuffle_algo, + shuffle_seed, shuffle_block_size, sampling_method, + sampling_granularity, batching_method) + + return dataset + diff --git a/simulator/imgs/downloads.png b/simulator/imgs/downloads.png new file mode 100644 index 0000000000000000000000000000000000000000..802686bd448582d043d92f163ee4c74527910f5e GIT binary patch literal 97500 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≫y95*_AG*hv&B4Zovbwx|f{-4&g^_!! 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͈AWG*H?HnnZbJ8~ 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 literal 0 HcmV?d00001 diff --git a/simulator/requirements.txt b/simulator/requirements.txt new file mode 100644 index 000000000..ef17a6e60 --- /dev/null +++ b/simulator/requirements.txt @@ -0,0 +1,10 @@ +altair==5.1.1 +matplotlib==3.7.2 +mosaicml_streaming==0.6.0 +numpy==1.24.4 +omegaconf==2.3.0 +pandas==2.0.3 +PyYAML==6.0 +sortedcollections==2.1.0 +streamlit==1.26.0 +wandb==0.15.5 diff --git a/simulator/simulation/interface_utils.py b/simulator/simulation/interface_utils.py new file mode 100644 index 000000000..cb1a65b40 --- /dev/null +++ b/simulator/simulation/interface_utils.py @@ -0,0 +1,106 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Peripheral functions for interface functionality.""" + +import numpy as np +from numpy.typing import NDArray +from omegaconf import DictConfig +from omegaconf import OmegaConf as om +from omegaconf import SCMode +from typing import Optional +from core.utils import get_rolling_avg_throughput +from streaming.base.util import number_abbrev_to_int + +def plot_simulation(step_times: NDArray, + step_downloads: NDArray, + window: int = 10): + """Plots simulation results for web UI or local script. + + Args: + step_times (NDArray): time per step, as calculated by simulation + step_downloads (NDArray): download size (bytes) per step, as calculated by simulation + window (int, optional): window size to calculate batch throughput over. Defaults to ``10``. + + Returns: + Optional[bytes]: bytes of plot image if ``web`` is ``True``, else plot is displayed, and returns ``None``. + """ + import matplotlib.pyplot as plt + + immediate_batch_throughput = 1 / step_times + + step_downloads_cumulative = np.cumsum(step_downloads) + + batch_throughput_rolling_avg = get_rolling_avg_throughput(step_times, window) + + # matplotlib plot with 2 vertically stacked subplots + fig, (ax1, ax2) = plt.subplots(2, 1) + + plt.suptitle('Simulation Results', fontsize=16) + + ax1.plot(np.arange(immediate_batch_throughput.shape[0]), + immediate_batch_throughput, + color='lightblue', + label='per step throughput') + ax1.plot(np.arange(batch_throughput_rolling_avg.shape[0]), + batch_throughput_rolling_avg, + color='darkblue', + label='rolling throughput (10 step avg)') + ax1.legend() + ax1.set_ylim([0, max(immediate_batch_throughput) * 1.1]) + ax1.set_ylabel('batches/s') + ax1.set_title('batch throughput (batches/s)') + + ax2.plot(np.arange(step_downloads_cumulative.shape[0]), + step_downloads_cumulative, + color='blue', + label='total') + ax2.set_ylim([0, max(step_downloads_cumulative) * 1.1]) + ax2.set_xlabel('step') + ax2.set_ylabel('cumulative download (bytes)') + ax2.set_title('network traffic (bytes)') + + fig.set_figheight(8) + fig.set_figwidth(6) + + plt.show() + +def get_train_dataset_params(input_params: dict, create_indices: bool = False, old_params: Optional[DictConfig] = None) -> DictConfig: + train_dataset_params = {} + train_dataset_params["epoch_size"] = input_params["epoch_size"] + train_dataset_params["batch_size"] = input_params["device_batch_size"] + train_dataset_params["nodes"] = input_params["physical_nodes"] + train_dataset_params["devices"] = input_params["devices"] + train_dataset_params["workers"] = input_params["workers"] + train_dataset_params["num_canonical_nodes"] = input_params["canonical_nodes"] + train_dataset_params["predownload"] = input_params["predownload"] + train_dataset_params["cache_limit"] = input_params["cache_limit"] + train_dataset_params["shuffle"] = input_params["shuffle"] + train_dataset_params["shuffle_algo"] = input_params["shuffle_algo"] + train_dataset_params["shuffle_block_size"] = number_abbrev_to_int( + input_params["shuffle_block_size"]) + train_dataset_params["shuffle_seed"] = input_params["seed"] + train_dataset_params["sampling_method"] = input_params["sampling_method"] + train_dataset_params["sampling_granularity"] = input_params["sampling_granularity"] + train_dataset_params["batching_method"] = input_params["batching_method"] + if create_indices: + train_dataset_params["indices_created"] = True + + # If there were old params, fill them in. + if old_params is not None: + existing_params_set = set(train_dataset_params.keys()) + + old_params = om.to_container(old_params, + resolve=False, + throw_on_missing=True, + structured_config_mode=SCMode.INSTANTIATE) + old_params_set = set(old_params.keys()) + # Keep params that were set in yaml but not accessible by the user in the UI. + # This includes the old_params "local"/"remote" or "streams". + for param in old_params_set - existing_params_set: + train_dataset_params[param] = old_params[param] + else: + # If there are no old params, we need to set streams to what the user provided. + train_dataset_params["streams"] = input_params["streams"] + + return om.create(train_dataset_params) diff --git a/simulator/simulation/simcli.py b/simulator/simulation/simcli.py new file mode 100644 index 000000000..9fe22c33b --- /dev/null +++ b/simulator/simulation/simcli.py @@ -0,0 +1,98 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""simcli: simulate your training yaml from the command line.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from core.main import simulate +from interface_utils import plot_simulation +from core.utils import get_simulation_stats +import argparse +from core.yaml_processing import ingest_yaml, create_simulation_dataset +from streaming.base.util import bytes_to_int + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Simulate your training yaml from the command line.') + parser.add_argument('-f', '--file', type=str, help='path to yaml file', required=True) + parser.add_argument('-n', '--nodes', type=int, help='number of physical nodes', required=False) + parser.add_argument('-d', '--devices', type=int, help='number of devices per node', required=False) + parser.add_argument('-t', '--time_per_sample', type=float, help='time to process one sample on one device (seconds)', required=False) + parser.add_argument('-b', '--node_internet_bandwidth', type=str, help='internet bandwidth per node (bytes/s)', required=False) + args = parser.parse_args() + + # Read in yaml file + filepath = args.file + total_devices, workers, max_duration, global_batch_size, train_dataset = ingest_yaml(filepath=filepath) + + # Check if we have to ask for any parameters + args = parser.parse_args() + nodes = args.nodes + if nodes is None: + nodes = int(input("Number of physical nodes: ")) + # devices may be specified in the yaml file. + if total_devices is None: + devices = args.devices + else: + if total_devices % nodes != 0: + raise ValueError("The number of devices must be divisible by the number of nodes.") + devices = total_devices // nodes + time_per_sample = args.time_per_sample + node_network_bandwidth = args.node_internet_bandwidth + if devices is None: + devices = int(input("Number of devices per node: ")) + if time_per_sample is None: + time_per_sample = float(input("Time to process one sample on one device (seconds): ")) + if node_network_bandwidth is None: + bandwidth_input = input("Internet bandwidth per node (bytes/s): ") + try: + node_network_bandwidth = float(bandwidth_input) + except ValueError: + node_network_bandwidth = bandwidth_input + + # Convert strings into numbers for applicable args + node_network_bandwidth = bytes_to_int(node_network_bandwidth) + + # Create SimulationDataset + print("Constructing SimulationDataset...") + dataset = create_simulation_dataset(nodes, devices, workers, global_batch_size, train_dataset) + + # Simulate Run + step_times, step_downloads, startup_time, min_cache_limit = next( + simulate(dataset, time_per_sample, node_network_bandwidth, max_duration=max_duration)) + + print("Simulation Finished.") + + # Display simulation stats + total_batches = len(step_times) + cache_limit = dataset.get_cache_limit() + all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ + get_simulation_stats(step_times, time_per_sample, global_batch_size//(nodes*devices)) + print("\nSimulation Stats:") + print(f"Minimum cache limit needed: {min_cache_limit:,} bytes") + if cache_limit is not None and cache_limit < min_cache_limit: + # Cache limit is too low, and will cause shard redownloads / throughput drops. + print('⚠️ The provided cache limit is lower than the minimum cache limit needed to \ + prevent shard re-downloads. This can cause throughput issues.') + if warmup_step == total_batches: + # display error if the warmup phase is the whole run, so we never hit peak throughput. + print('🚨 This configuration is severely bottlenecked by downloading. The run will not be \ + performant.') + elif post_warmup_throughput_drops: + # display warning if post-warmup throughput drops are more than 10% of the run. + print('⚠️ This configuration experiences some downloading-related slowdowns even after \ + warmup.') + print("{0} steps, or {1:.1f}% of all steps, waited for shard \ + downloads.".format(all_throughput_drops, 100*all_throughput_drops/(total_batches))) + if warmup_step != total_batches: + # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) + print("There were {} steps that waited for shard downloads after the warmup \ + period.".format(post_warmup_throughput_drops)) + print("Estimated time to first batch: {0:.2f} s".format(startup_time)) + print("Estimated warmup time: {0:.2f} s".format(warmup_time)) + + # Plot simulation + plot_simulation(step_times, step_downloads) \ No newline at end of file diff --git a/simulator/simulation/simulation_script.py b/simulator/simulation/simulation_script.py new file mode 100644 index 000000000..164b1a3db --- /dev/null +++ b/simulator/simulation/simulation_script.py @@ -0,0 +1,111 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Script for simulating streaming and displaying results.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from core.main import simulate +from core.simulation_dataset import SimulationDataset +from core.create_index import create_stream_index +from core.sim_time import ensure_time, TimeUnit +from core.utils import get_simulation_stats +import matplotlib.pyplot as plt +import numpy as np +from streaming.base import Stream +from interface_utils import plot_simulation + +# Input Parameters + +# dataset +shards = 20850 # number of shards +samples_per_shard = 4093 # number of samples per shard +avg_raw_shard_size = 67092639 # average shard size (bytes) +avg_zip_shard_size = None # average compressed shard size (bytes) + +# training +max_duration = "1000ba" # max duration of training (batches: "ba", epochs: "ep") +epoch_size = None # epoch size (samples) +device_batch_size = 16 # device batch size (samples) + +# streaming +workers = 8 # number of workers per device +canonical_nodes = 2 # number of canonical nodes +predownload = 32 # number of samples to predownload per worker (samples) +cache_limit = None # cache limit (bytes) +shuffle = True # whether to shuffle dataset +shuffle_algo = 'py1b' # shuffling algorithm +shuffle_block_size = 16000000 # shuffling block size (samples) +seed = 17 # random seed + +# hardware and network +physical_nodes = 2 # number of physical nodes +devices = 8 # number of devices per node +time_per_sample = 0.0175 # time to process one sample on one device (seconds) +node_internet_bandwidth = 2e9 # network internet per node (bytes/s) + +# ---------------------------------------------- # + +# instantiate SimulationDataset on the same parameters for the new simulation function + +stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, + avg_zip_shard_size) +stream_folder = os.path.dirname(stream_indexpath) +stream = Stream(local=stream_folder) +max_duration = ensure_time(max_duration, TimeUnit.EPOCH) + +dataset = SimulationDataset( + nodes=physical_nodes, + devices=devices, + workers=workers, + streams=[stream], + epoch_size=epoch_size, + predownload=predownload, + cache_limit=cache_limit, + num_canonical_nodes=canonical_nodes, + batch_size=device_batch_size, + shuffle=True, + shuffle_algo=shuffle_algo, + shuffle_seed=seed, + shuffle_block_size=shuffle_block_size +) + +results = simulate(dataset=dataset, + time_per_sample=time_per_sample, + node_network_bandwidth=node_internet_bandwidth, + max_duration=max_duration) + +step_times, step_downloads, startup_time, min_cache_limit = next(results) +global_batch_size = device_batch_size * devices * physical_nodes + +# Display simulation stats +total_batches = len(step_times) +all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ + get_simulation_stats(step_times, time_per_sample, global_batch_size//(physical_nodes*devices)) +print("\nSimulation Stats:") +print(f"Minimum cache limit needed: {min_cache_limit:,} bytes") +if cache_limit is not None and cache_limit < min_cache_limit: + # Cache limit is too low, and will cause shard redownloads / throughput drops. + print('⚠️ The provided cache limit is lower than the minimum cache limit needed to \ + prevent shard re-downloads. This can cause throughput issues.') +if warmup_step == total_batches: + # display error if the warmup phase is the whole run, so we never hit peak throughput. + print('🚨 This configuration is severely bottlenecked by downloading. The run will not be \ + performant.') +elif post_warmup_throughput_drops: + # display warning if post-warmup throughput drops are more than 10% of the run. + print('⚠️ This configuration experiences some downloading-related slowdowns even after \ + warmup.') +print("{0} steps, or {1:.1f}% of all steps, waited for shard \ + downloads.".format(all_throughput_drops, 100*all_throughput_drops/(total_batches))) +if warmup_step != total_batches: + # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) + print("There were {} steps that waited for shard downloads after the warmup \ + period.".format(post_warmup_throughput_drops)) +print("Estimated time to first batch: {0:.2f} s".format(startup_time)) +print("Estimated warmup time: {0:.2f} s".format(warmup_time)) + +plot_simulation(step_times, step_downloads) \ No newline at end of file diff --git a/simulator/simulation/simulation_testing.py b/simulator/simulation/simulation_testing.py new file mode 100644 index 000000000..2ac53fb08 --- /dev/null +++ b/simulator/simulation/simulation_testing.py @@ -0,0 +1,168 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Test simulation results against run results from wandb.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +import wandb +import matplotlib.pyplot as plt +import numpy as np +from core.create_index import create_stream_index +from streaming.base import Stream +from core.simulation_dataset import SimulationDataset +from core.sim_time import ensure_time, TimeUnit +from core.main import simulate +import pandas as pd +import os + +api = wandb.Api() + +project_id = "mosaic-ml/streaming-shuffling-algo" +project_runs = api.runs(path=project_id, per_page=300) +project_runs_list = [run.id for run in project_runs] +skip = 0 + +# C4 neox compressed from OCI parameters +shards = 20850 +samples_per_shard = 4093 +avg_raw_shard_size = 67092639 +avg_zip_shard_size = 16000000 +time_per_sample = 0.0175 +node_network_bandwidth = 1e9 +throughput_window = 10 + +def get_similarity_percentage(real, sim): + real_copy = real.reshape(1, -1) + sim_copy = sim.reshape(1, -1) + merged = np.concatenate((real_copy, sim_copy), axis=0) + similarities = np.abs(real-sim)/np.max(merged, axis=0) + nanmean = np.nanmean(similarities) + return 1 - nanmean + +for run_id in project_runs_list[skip:]: + + run = api.run(f"{project_id}/{run_id}") + + print(run.name) + + summary = run.summary + config = run.config + + if '_step' not in summary: + print("skipping unsuccessful run") + continue + + # get parameters from run config and summary + max_duration_value = summary['_step'] + max_duration = ensure_time(str(max_duration_value) + "ba", TimeUnit.EPOCH) + devices = int(config["num_gpus_per_node"]) + physical_nodes = int(config['n_gpus']/devices) + # device_batch_size set for each run + device_batch_size = int(config['global_train_batch_size']/(physical_nodes*devices)) + canonical_nodes = int(config['num_canonical_nodes']) + workers = int(config["train_loader"]["num_workers"]) + predownload = int(config["train_loader"]["dataset"]["predownload"]) + cache_limit = None + if "cache_limit" in config["train_loader"]["dataset"]: + cache_limit = config["train_loader"]["dataset"]["cache_limit"] + shuffle_algo = None + if "shuffle_algo" in config["train_loader"]["dataset"]: + shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] + shuffle_block_size = config["train_loader"]["dataset"]["shuffle_block_size"] + seed = config['seed'] + + # get step timestamps, real throughput, and network use from the run + step_timestamps = run.history(samples=max_duration_value, keys=["_timestamp"], pandas=True) + real_batch_throughput = run.history(samples=max_duration_value-throughput_window, keys=["throughput/batches_per_sec"], pandas=True) + + real_network_use = run.history(stream="system", pandas=True)[["_timestamp", "system.network.recv"]] + + # merge real_network_use with step_timestamps + merged_network_use = pd.merge_asof(real_network_use, step_timestamps, on="_timestamp", direction="nearest") + + # simulate throughput and network use given the inputs + stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, + avg_zip_shard_size) + stream_folder = os.path.dirname(stream_indexpath) + stream = Stream(local=stream_folder) + + dataset = SimulationDataset( + nodes=physical_nodes, + devices=devices, + workers=workers, + streams=[stream], + predownload=predownload, + cache_limit=cache_limit, + num_canonical_nodes=canonical_nodes, + batch_size=device_batch_size, + shuffle=True, + shuffle_algo=shuffle_algo, + shuffle_seed=seed, + shuffle_block_size=shuffle_block_size + ) + + results = simulate(dataset=dataset, + time_per_sample=time_per_sample, + node_network_bandwidth=node_network_bandwidth, + max_duration=max_duration) + + step_times, step_downloads, startup_time, min_cache_limit = next(results) + + immediate_batch_throughput = 1 / step_times + + shard_downloads_cumulative = np.cumsum(step_downloads) + shard_downloads_steps = np.arange(step_downloads.shape[0]) + sim_downloads = pd.DataFrame({"_step": shard_downloads_steps, + "sim_downloads": shard_downloads_cumulative}) + # merge simulated downloads with real downloads dataframe + merged_network_use = pd.merge_asof(merged_network_use, sim_downloads, + on="_step", direction="nearest") + + step_times_rolling_avg = np.convolve(step_times, + np.ones(throughput_window) / throughput_window, + mode='valid')[:-1] + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + sim_throughput = pd.DataFrame({"_step": throughput_window + \ + np.arange(batch_throughput_rolling_avg.shape[0]), + "sim_throughput": batch_throughput_rolling_avg}) + merged_throughput = pd.merge_asof(real_batch_throughput, sim_throughput, + on="_step", direction="nearest") + + # get similarity scores + throughput_similarity = get_similarity_percentage( + merged_throughput["throughput/batches_per_sec"].to_numpy(), + merged_throughput["sim_throughput"].to_numpy()) + network_similarity = get_similarity_percentage( + physical_nodes*(merged_network_use["system.network.recv"].to_numpy()), + (merged_network_use["sim_downloads"].to_numpy())) + + # print params and results to easily paste to spreadsheet + print(run.name, seed, canonical_nodes, physical_nodes, + predownload, shuffle_algo,shuffle_block_size, + cache_limit, max_duration_value, throughput_similarity, network_similarity) + + fig, (ax1, ax2) = plt.subplots(2, 1) + + ax1.set_title("throughput - score: " + str(throughput_similarity)) + ax1.plot(merged_throughput["_step"], merged_throughput["throughput/batches_per_sec"], + color="red", label="real") + ax1.plot(merged_throughput["_step"], merged_throughput["sim_throughput"], + color="blue", label="sim") + ax1.legend() + + ax2.set_title("network use - score: " + str(network_similarity)) + # wandb only logs network use for node 0. multiply by number of nodes to get total network use + ax2.plot(merged_network_use["_timestamp"], + physical_nodes*merged_network_use["system.network.recv"], color="red", label="real") + # simulation assumes all shards are downloaded uncompressed (overestimates). + ax2.plot(merged_network_use["_timestamp"], + merged_network_use["sim_downloads"], color="blue", label="sim") + ax2.legend() + + fig.set_figheight(8) + + plt.show() \ No newline at end of file diff --git a/simulator/simulation/simulation_ui.py b/simulator/simulation/simulation_ui.py new file mode 100644 index 000000000..1bf785f4c --- /dev/null +++ b/simulator/simulation/simulation_ui.py @@ -0,0 +1,339 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""simulator web UI using streamlit.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +import streamlit as st +import numpy as np +import pandas as pd +from io import StringIO +from core.main import simulate +from core.simulation_dataset import SimulationDataset +from core.utils import get_simulation_stats, get_total_batches +from core.sim_time import Time +from core.yaml_processing import ingest_yaml, create_simulation_dataset +from core.create_index import create_stream_index +from core.shuffle_quality import analyze_shuffle_quality +from interface_utils import get_train_dataset_params +from widgets import param_inputs, get_line_chart, get_shuffle_quality_chart,\ + display_simulation_stats, display_shuffle_quality_graph +import yaml +from typing import Optional, Union +from concurrent.futures import ProcessPoolExecutor +from streaming.base.util import bytes_to_int, number_abbrev_to_int +from functools import partial, reduce + + +# set up page +st.set_page_config(layout="wide") +col1, space, col2 = st.columns((10, 1, 6)) +col2.title("Streaming Simulator") +col2.write("Enter run parameters in the left panel.") +col2.text("") +progress_bar = col1.progress(0) +status_text = col1.empty() +col1.text("") +throughput_plot = col2.empty() +network_plot = col2.empty() +sim_stats = col2.empty() +col2.text("") +shuffle_quality_plot = col2.empty() +throughput_window = 10 +shuffle_quality_algos = ["naive", "py1b", "py1br", "py1e", "py1s", "py2s", "none"] + +# Identity function for executor.map since it doesn't like lambdas. +def return_input(x): + return x + +def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_sample: float, + node_internet_bandwidth: Union[float,str], max_duration: Time): + total_batches = get_total_batches(dataset=dataset, max_duration=max_duration) + node_internet_bandwidth = bytes_to_int(node_internet_bandwidth) + cache_limit = dataset.get_cache_limit() + gen_sim = simulate(dataset, time_per_sample, node_internet_bandwidth, + generator=True, max_duration=max_duration) + gen_step_times = [] + gen_step_downloads = [] + rolling_throughput_data = [] + immediate_throughput_data = [] + network_data = [] + steps = [] + time_to_first_batch = 0 + futures = [] + shuffle_quality_graphed = False + # Define partial function to pass to executor map for simulation. + with ProcessPoolExecutor(max_workers=8) as executor: + # Submit shuffle quality job to executor. + if shuffle_quality: + col1.write("Starting shuffle quality analysis...") + input_params = st.session_state["input_params"] + # Use multiprocessing to get the shuffle quality results. + canonical_nodes = input_params["canonical_nodes"] + physical_nodes = input_params["physical_nodes"] + devices = input_params["devices"] + workers = input_params["workers"] + device_batch_size = input_params["device_batch_size"] + shuffle_block_size = number_abbrev_to_int(input_params["shuffle_block_size"]) + samples_per_shard = dataset.get_avg_samples_per_shard() + epoch_size = dataset.get_epoch_size() + if epoch_size > 100000000: + st.warning('Epoch size is over 100 million samples. Shuffle quality analysis \ + will be conducted only on the first 100 million samples.', icon="⚠️") + seed = input_params["seed"] + # Submit all shuffle quality analysis jobs to executor. + futures = [executor.submit(analyze_shuffle_quality, algo, canonical_nodes, + physical_nodes, devices, workers, device_batch_size, + shuffle_block_size, samples_per_shard, epoch_size, seed) + for algo in shuffle_quality_algos] + + # Simulate only on the main worker, otherwise it's super slow. + for output in gen_sim: + # If output is a length 2, it is the time to first batch and min cache limit. + # Otherwise it is the step, step time, and shard download from the simulation. + if len(output) == 2: + step = total_batches - 1 + time_to_first_batch, min_cache_limit = output + else: + # gen_step_times.append(step_time) + step, step_time, shard_download = output + gen_step_times.append(step_time) + gen_step_downloads.append(shard_download) + # plot throughput once we have enough samples for the window + rolling_throughput = 0 + if step >= throughput_window - 1: + step_time_window = np.array(gen_step_times[-throughput_window:]) + rolling_throughput = 1/np.mean((step_time_window)) + rolling_throughput_data.append(rolling_throughput) + immediate_throughput_data.append(1/step_time) + # plot network usage + cumulative_shard_download = np.sum(np.array(gen_step_downloads)) + network_data.append(cumulative_shard_download) + steps.append(step+1) + + # update plots and percentages at regular intervals + plot_interval = (total_batches) // 15 + if step == 1 or step % plot_interval == 0 or step == total_batches - 1: + rolling_throughput_df = pd.DataFrame({"step": steps, "measurement": [" rolling avg"]*len(rolling_throughput_data), "throughput (batches/s)": rolling_throughput_data}) + throughput_df = rolling_throughput_df + network_df = pd.DataFrame({"step": steps, "cumulative network usage (bytes)": network_data}) + throughput_plot.altair_chart(get_line_chart(throughput_df, throughput_window, True), use_container_width=True) + network_plot.altair_chart(get_line_chart(network_df, throughput_window, False), use_container_width=True) + # update progress bar and text + percentage = int(100*(step+1) / (total_batches)) + status_text.text("%i%% Complete" % percentage) + progress_bar.progress(percentage) + + # If applicable, check if the shuffle quality tasks are finished, and graph. + if shuffle_quality and all([f.done() for f in futures]) \ + and not shuffle_quality_graphed: + display_shuffle_quality_graph(futures, shuffle_quality_plot) + shuffle_quality_graphed = True + + gen_step_times = np.array(gen_step_times) + gen_step_downloads = np.array(gen_step_downloads) + device_batch_size = dataset.get_batch_size() + display_simulation_stats(sim_stats, total_batches, gen_step_times, time_per_sample, + device_batch_size, time_to_first_batch, min_cache_limit, + cache_limit) + + # If shuffle quality still hasn't been graphed yet, we get the result and graph it. + if shuffle_quality and not shuffle_quality_graphed: + display_shuffle_quality_graph(futures, shuffle_quality_plot) + shuffle_quality_graphed = True + +# Function used to prevent clicking shuffle quality from reloading the whole page. +def clicked_shuffle_quality(): + st.session_state["clicked_shuffle_quality"] = True + +def get_input_params_initial(physical_nodes, devices, workers, global_batch_size, train_dataset, + max_duration, time_per_sample, node_internet_bandwidth): + try: + st.session_state["creating_dataset"] = True + dataset = create_simulation_dataset(physical_nodes, devices, workers, + global_batch_size, train_dataset) + st.session_state["orig_dataset"] = dataset + input_params = {} + # dataset input_params + input_params["streams"] = dataset.get_stream_info() + # training input_params + input_params["max_duration"] = max_duration + input_params["epoch_size"] = dataset.get_epoch_size() + input_params["device_batch_size"] = dataset.get_batch_size() + # hardware and network input_params + input_params["physical_nodes"] = physical_nodes + input_params["devices"] = devices + input_params["time_per_sample"] = time_per_sample + input_params["node_network_bandwidth"] = node_internet_bandwidth + # streaming input_params + input_params["workers"] = workers + input_params["canonical_nodes"] = dataset.get_num_canonical_nodes() + input_params["predownload"] = dataset.get_predownload() + input_params["shuffle"] = dataset.get_shuffle() + input_params["shuffle_algo"] = dataset.get_shuffle_algo() + input_params["shuffle_block_size"] = dataset.get_shuffle_block_size() + input_params["seed"] = dataset.get_shuffle_seed() + input_params["cache_limit"] = dataset.get_cache_limit() + input_params["sampling_method"] = dataset.get_sampling_method() + input_params["sampling_granularity"] = dataset.get_sampling_granularity() + input_params["batching_method"] = dataset.get_batching_method() + # Save input_params and originally created dataset to session state. + st.session_state["input_params"] = input_params + except FileNotFoundError: + st.error('Please wait until the dataset is loaded before changing toggle values too \ + quickly. Doing so can cause issues with creating multiple datasets, since \ + Streamlit reloads widgets every single time a toggle value changes.', icon="🚨") + +# Define parameter input area. + +# Check if the user wants to submit a yaml file. +use_yaml = col1.toggle(":sparkles: **Use `yaml`** :sparkles:", value=True) + +if use_yaml: + uploaded_yaml = col1.file_uploader("Upload a yaml file", type=["yaml"]) + if uploaded_yaml is not None: + string_yaml = StringIO(uploaded_yaml.getvalue().decode("utf-8")).read() + dict_yaml = yaml.safe_load(string_yaml) + total_devices, workers, max_duration, global_batch_size, train_dataset = \ + ingest_yaml(yaml_dict=dict_yaml) + physical_nodes = None + time_per_sample = None + node_internet_bandwidth = None + # Check which parameters we still need to ask for. + col1.write("The parameters below were not found in your yaml file. Enter them here:") + if physical_nodes is None: + physical_nodes = col1.number_input('number of physical nodes', step=1, value=1, help="number of physical nodes for this run. a node typically consists of 8 devices (GPUs).") + # Using physical_nodes, calculate number of devices per node. + if total_devices is None: + devices = col1.number_input('devices per node', step=1, value=8, help="number of devices (GPUs) per node for this run. there are typically 8 devices per node.") + else: + if total_devices % physical_nodes != 0: + raise ValueError("The number of devices must be divisible by the number of nodes.") + devices = total_devices // physical_nodes + if time_per_sample is None: + time_per_sample = col1.number_input('process time per sample (s)', step = 0.0005, value=0.0175, format="%.4f", help="time for one device to process one sample from your dataset.") + if node_internet_bandwidth is None: + node_internet_bandwidth = col1.text_input('network bandwidth per node (bytes/s)', + value="1GB", + help="network bandwidth available to each \ + node. in practice, network bandwidth is \ + variable and is affected by many factors, \ + including cluster demand.") + + submitted = col1.button("Simulate Run", use_container_width=True) + shuffle_quality = col1.toggle("Analyze Shuffle Quality", value=False, + help="Analyze shuffle qualities for this run for different \ + shuffle algos using an entropy-based metric. ⚠️ **Results \ + are *noisy estimates* and may not reflect the true \ + shuffle quality.**") + modify_params = col1.toggle("Modify Parameters", value=False) + + # Display components and take actions based on the values of the above three buttons. + if modify_params: + # Create dataset and input_params if it doesn't already exist. + if "input_params" not in st.session_state: + col1.write("Preparing dataset for modification...") + get_input_params_initial(physical_nodes, devices, workers, global_batch_size, + train_dataset, max_duration, time_per_sample, + node_internet_bandwidth) + # We have input_params in the session state. Use it to populate the form. + defaults = st.session_state["input_params"] + # Define parameter input area with default values. + input_params = {} + param_inputs(col1, input_params, defaults=defaults) + # input_params has been repopulated with new values. Save to session state. + st.session_state["input_params"] = input_params + + if submitted: + # Create dataset if it is not yet present. + if "input_params" not in st.session_state: + col1.write("Preparing dataset for this run...") + get_input_params_initial(physical_nodes, devices, workers, global_batch_size, + train_dataset, max_duration, time_per_sample, + node_internet_bandwidth) + # If modify_params is false, we submit the jobs using the original dataset from yaml. + if not modify_params: + col1.write("Starting Simulation...") + dataset = st.session_state["orig_dataset"] + # shuffle_quality is passed through to the job submission function. + submit_jobs(shuffle_quality, dataset, time_per_sample, + node_internet_bandwidth, max_duration) + else: + # If modify_params is true, we retrieve the most recent input params from session + # state, create a new dataset, and submit the jobs. + col1.write("Preparing dataset with modifications...") + # Get parameters for new SimulationDataset from input_params and train_dataset. + input_params = st.session_state["input_params"] + train_dataset = get_train_dataset_params(input_params, old_params=train_dataset) + # Get the rest of the needed params from the new inputs + physical_nodes = input_params["physical_nodes"] + devices = input_params["devices"] + global_batch_size = input_params["device_batch_size"] * devices * physical_nodes + workers = input_params["workers"] + max_duration = input_params["max_duration"] + time_per_sample = input_params["time_per_sample"] + node_internet_bandwidth = input_params["node_network_bandwidth"] + # Make sure node_internet_bandwidth is an int. + dataset = create_simulation_dataset(physical_nodes, devices, workers, + global_batch_size, train_dataset) + col1.write("Starting Simulation...") + submit_jobs(shuffle_quality, dataset, time_per_sample, + node_internet_bandwidth, max_duration) +else: + submitted = col1.button("Simulate Run", use_container_width=True) + col1.text("") + shuffle_quality = col1.toggle("Analyze Shuffle Quality", value=False, + help="Analyze shuffle qualities for this run for different \ + shuffle algos using an entropy-based metric. ⚠️ **Results \ + are *noisy estimates* and may not reflect the true \ + shuffle quality.**") + if "input_params" in st.session_state: + st.session_state["input_params"] = {} + input_params = {} + param_inputs(col1, input_params, defaults=input_params) + if submitted: + # Params have been submitted. Create new dataset and proceed with simulation. + col1.write("Preparing dataset for this run...") + # Create index files and Stream object for each stream. + streams = {} + for stream_idx, stream in input_params["streams"].items(): + stream_dict = {} + if "path" in stream: + # Case when user has provided a path to an index.json file. + stream_folder = os.path.dirname(stream["path"]) + if stream["path_type"] == "local": + stream_dict["local"] = stream_folder + else: + stream_dict["remote"] = stream_folder + else: + # Case when user provides estimates for stream characteristics. + index_path = create_stream_index(stream["shards"], stream["samples_per_shard"], stream["avg_raw_shard_size"], stream["avg_zip_shard_size"]) + stream_folder = os.path.dirname(index_path) + stream_dict["local"] = stream_folder + stream_dict["proportion"] = stream["proportion"] + stream_dict["repeat"] = stream["repeat"] + stream_dict["choose"] = stream["choose"] + streams[stream_idx] = stream_dict + input_params["streams"] = streams + # Get parameters for new SimulationDataset from input_params and train_dataset. + train_dataset = get_train_dataset_params(input_params, create_indices=True) + # Get the rest of the needed params from the new inputs + physical_nodes = input_params["physical_nodes"] + devices = input_params["devices"] + global_batch_size = input_params["device_batch_size"] * devices * physical_nodes + workers = input_params["workers"] + max_duration = input_params["max_duration"] + time_per_sample = input_params["time_per_sample"] + node_internet_bandwidth = input_params["node_network_bandwidth"] + dataset = create_simulation_dataset(physical_nodes, devices, workers, global_batch_size, train_dataset) + # Make sure input_params is in session state. + st.session_state["input_params"] = input_params + col1.write("Starting Simulation...") + submit_jobs(shuffle_quality, dataset, time_per_sample, + node_internet_bandwidth, max_duration) + + \ No newline at end of file diff --git a/simulator/simulation/widgets.py b/simulator/simulation/widgets.py new file mode 100644 index 000000000..6081cef13 --- /dev/null +++ b/simulator/simulation/widgets.py @@ -0,0 +1,343 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Streamlit UI Widgets.""" + +import altair as alt +from streaming.base.util import bytes_to_int +from core.sim_time import TimeUnit, ensure_time +from core.utils import get_simulation_stats +from numpy.typing import NDArray +import streamlit as st +import pandas as pd +from typing import Optional + +def get_line_chart(data, throughput_window, throughput=True): + hover = alt.selection_point( + fields=["step"], + nearest=True, + on="mouseover", + empty=False, + ) + + lines = ( + alt.Chart(data, title="Throughput (" + str(throughput_window) + "-step rolling average)") + .mark_line() + .encode( + x="step", + y="throughput (batches/s)", + ) + ) if throughput else ( + alt.Chart(data, title="Cumulative Network Usage, all nodes") + .mark_line() + .encode( + x="step", + y="cumulative network usage (bytes)" + ) + ) + + # Draw points on the line, and highlight based on selection + points = lines.transform_filter(hover).mark_circle(size=65) + + # Draw a rule at the location of the selection + tooltips = ( + alt.Chart(data) + .mark_rule() + .encode( + x="step", + y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)", + opacity=alt.condition(hover, alt.value(0.3), alt.value(0)), + tooltip=[ + alt.Tooltip("step", title="Step"), + alt.Tooltip("throughput (batches/s)" if throughput else "cumulative network usage (bytes)", title="Throughput" if throughput else "Network Usage"), + ], + ) + .add_params(hover) + ) + return (lines + points + tooltips).interactive() + +def stream_entry(col, streams, key, add_stream: bool = True, defaults: dict = None): + stream_entries = {} + col.write(f"*Stream {key+1}*") + on = col.toggle("use `index.json`", key=str(key)+"toggle") if add_stream else None + if on or not add_stream: + path = col.text_input("path to `index.json`", + value="/absolute/path/to/index.json" + if defaults is None else defaults["path"], + help="path to the `index.json` file for this stream. \ + the `index.json` file contains information about the shards in \ + your dataset.", key=str(key)+"path", disabled=(not add_stream)) + if add_stream: + path_type = col.selectbox('path type', ["local", "remote"], key=str(key)+"path_type") + stream_entries["path_type"] = path_type + stream_entries["path"] = path + else: + shards = col.number_input('number of shards', step=1, value=20850, help="number of total \ + shards across your whole dataset.", key=str(key)+"shards") + samples_per_shard = col.number_input('samples per shard', step=1, value=4093, + help="average number of samples contained \ + in each shard.", key=str(key)+"samples") + avg_raw_shard_size = col.text_input('avg raw shard size (bytes)', value="67MB", + help="average raw size, in bytes, \ + of a single shard.", key=str(key)+"rawsize") + avg_raw_shard_size = bytes_to_int(avg_raw_shard_size) + avg_zip_shard_size = col.text_input('avg compressed shard size (bytes)', value="None", + help="average compressed size, in bytes, \ + of a single shard.", key=str(key)+"zipsize") + avg_zip_shard_size = None if avg_zip_shard_size == "None" \ + else bytes_to_int(avg_zip_shard_size) + stream_entries["shards"] = shards + stream_entries["samples_per_shard"] = samples_per_shard + stream_entries["avg_raw_shard_size"] = avg_raw_shard_size + stream_entries["avg_zip_shard_size"] = avg_zip_shard_size + proportion = col.text_input('proportion', + value="None" if defaults is None else defaults["proportion"], + help="proportion of the full training dataset that this stream \ + represents.", key=str(key)+"proportion", + disabled=(not add_stream)) + proportion = float(proportion) if proportion != "None" else None + repeat = col.text_input('repeat', + value="None" if defaults is None else defaults["repeat"], + help="number of times to repeat the samples in this \ + stream.", key=str(key)+"repeat", + disabled=(not add_stream)) + repeat = float(repeat) if repeat != "None" else None + choose = col.text_input('choose', + value="None" if defaults is None else defaults["choose"], + help="number of samples to choose from this \ + stream.", key=str(key)+"choose", + disabled=(not add_stream)) + choose = int(choose) if choose != "None" else None + stream_entries["proportion"] = proportion + stream_entries["repeat"] = repeat + stream_entries["choose"] = choose + + streams[key] = stream_entries + if add_stream and col.checkbox(label="add stream", key=str(key)+"checkbox"): + stream_entry(col, streams, key+1) + +def param_inputs(col, input_params: dict, defaults: dict = {}): + """Define parameter input area.""" + col3, col4, col5 = col.columns(3) + + # dataset + streams = {} + col3.write("**Dataset Parameters**") + if "streams" in defaults: + key = 0 + for _, stream in defaults["streams"].items(): + # Case is only possible when reading in streams from yaml file. Stream will have path. + stream_entry(col3, streams, key, add_stream=False, defaults=stream) + key += 1 + streams = defaults["streams"] + else: + stream_entry(col3, streams, 0, add_stream=True) + col3.text("") + input_params["streams"] = streams + + # training + col4.write("**Training Parameters**") + if "max_duration" in defaults: + default_max_duration = defaults["max_duration"] + default_value = int(default_max_duration.value) + default_unit_index = 0 if default_max_duration.unit == TimeUnit.BATCH else 1 + time_value = col4.number_input('training duration', step=1, + value = default_value, + help="training duration value, in specified units.") + time_units = col4.selectbox('units', ["batches", "epochs"], + index = default_unit_index, + help="units of training duration.") + else: + time_value = col4.number_input('training duration', step=1, + value=1000, + help="training duration value, in specified units.") + time_units = col4.selectbox('units', ["batches", "epochs"], + help="units of training duration.") + # Get Time object from inputs + time_string = str(time_value) + time_string += "ba" if time_units == "batches" else "ep" + max_duration = ensure_time(time_string, TimeUnit.EPOCH) + epoch_size = col4.text_input('epoch size (samples)', value="", + help="epoch size for this run, in samples.") + epoch_size = None if epoch_size == "" or epoch_size == "None" else int(epoch_size) + device_batch_size = col4.number_input('device batch size', step=1, + value=16 if "device_batch_size" not in defaults + else defaults["device_batch_size"], + help="number of samples per device (GPU) per batch. \ + the global batch size is `device_batch_size * \ + devices_per_node * physical_nodes`") + col4.text("") + input_params["max_duration"] = max_duration + input_params["epoch_size"] = epoch_size + input_params["device_batch_size"] = device_batch_size + + # hardware and network + col4.write("**Hardware and Network Parameters**") + physical_nodes = col4.number_input('number of physical nodes', step=1, + value=1 if "physical_nodes" not in defaults + else defaults["physical_nodes"], + help="number of physical nodes for this run. \ + a node typically consists of 8 devices (GPUs).") + devices = col4.number_input('devices per node', step=1, + value=8 if "devices" not in defaults else defaults["devices"], + help="number of devices (GPUs) per node for this run. \ + there are typically 8 devices per node.") + time_per_sample = col4.number_input('process time per sample (s)', step = 0.0005, + value=0.0175 if "time_per_sample" not in defaults + else defaults["time_per_sample"], + format="%.4f", help="time for one device to process one \ + sample from your dataset.") + node_network_bandwidth = col4.text_input('network bandwidth per node (bytes/s)', + value="500MB" if "node_network_bandwidth" not in defaults + else defaults["node_network_bandwidth"], + help="network bandwidth available to \ + each node. in practice, network bandwidth is \ + variable and is affected by many factors, \ + including cluster demand.") + col4.text("") + input_params["physical_nodes"] = physical_nodes + input_params["devices"] = devices + input_params["time_per_sample"] = time_per_sample + input_params["node_network_bandwidth"] = node_network_bandwidth + + # streaming + col5.write("**Streaming Parameters**") + workers = col5.number_input('workers per device', step=1, + value=8 if "workers" not in defaults else defaults["workers"], + help="number of dataloader \workers per device (GPU).") + canonical_nodes = col5.number_input('number of canonical nodes', step=1, + value=2 if "canonical_nodes" not in defaults + else defaults["canonical_nodes"], + help="number of canonical nodes to split your dataset \ + into. a canonical node is a bucket of shards that is \ + assigned to a particular physical node.") + predownload = col5.text_input('predownload per worker (samples)', + value="None" if "predownload" not in defaults + else defaults["predownload"], + help="number of samples ahead each worker should download. \ + predownload does not occur before the first batch; \ + rather, it occurs while training is ongoing.") + predownload = None if predownload == "" or predownload == "None" else int(predownload) + shuffle = col5.checkbox(label="shuffle", value=True if "shuffle" not in defaults + else defaults["shuffle"], + help="whether or not to shuffle the samples for this run.") + shuffle_algo="py1e" if defaults is None or "shuffle_algo" not in defaults \ + else defaults["shuffle_algo"] + shuffle_block_size="1M" if defaults is None or "shuffle_block_size" not in defaults \ + else defaults["shuffle_block_size"] + seed=42 if defaults is None or "seed" not in defaults else defaults["seed"] + if shuffle: + algos = ["py1e", "py1br", "py1b", "py1s", "py2s", "naive"] + default_index = 0 + if "shuffle_algo" in defaults: + default_index = algos.index(defaults["shuffle_algo"]) + shuffle_algo = col5.selectbox('shuffling algorithm', algos, index=default_index, + help="shuffling algorithm to use for this run. your shuffle \ + parameters may affect model training.") + shuffle_block_size = col5.text_input('shuffle block size (samples)', + value="10M" if "shuffle_block_size" not in defaults + else defaults["shuffle_block_size"], + help="shuffle block size for this run. \ + used in the `py1b`, `py1br`, and `py1e` \ + shuffling algorithms, samples in blocks of \ + `shuffle_block_size` are randomly shuffled \ + inside each bucket of shards (aka canonical node).") + seed = col5.number_input('shuffle seed', step=1, + value=42 if "seed" not in defaults else defaults["seed"], + help="random seed for shuffling.") + cache_limit = col5.text_input('cache limit (bytes)', + value="None" if "cache_limit" not in defaults + else defaults["cache_limit"], + help="cache limit per node for this run. \ + setting cache limit too low will impact throughput.") + cache_limit = None if cache_limit=="" or cache_limit=="None" else bytes_to_int(cache_limit) + sampling_methods = ["balanced", "fixed"] + sampling_method = col5.selectbox('sampling method', sampling_methods, + index=0 if "sampling_method" not in defaults + else sampling_methods.index(defaults["sampling_method"]), + help="sampling method for this run. controls how samples are\ + chosen each epoch. can be either 'balanced' or 'fixed'.") + sampling_granularity = col5.number_input('sampling granularity', step=1, + value=1 if "sampling_granularity" not in defaults + else defaults["sampling_granularity"], + help="sampling granularity for this run. controls how\ + samples are balanced across shards. higher values will\ + cause more samples to be drawn from each shard at a time.") + batching_methods = ["random", "per_stream", "stratified"] + batching_method = col5.selectbox('batching method', batching_methods, + index=0 if "batching_method" not in defaults + else batching_methods.index(defaults["batching_method"]), + help="batching method for this run. controls how batches\ + are constructed.") + col5.text("") + input_params["workers"] = workers + input_params["canonical_nodes"] = canonical_nodes + input_params["predownload"] = predownload + input_params["cache_limit"] = cache_limit + input_params["shuffle"] = shuffle + input_params["shuffle_algo"] = shuffle_algo + input_params["shuffle_block_size"] = shuffle_block_size + input_params["seed"] = seed + input_params["sampling_method"] = sampling_method + input_params["sampling_granularity"] = sampling_granularity + input_params["batching_method"] = batching_method + +def display_simulation_stats(component, total_batches: int, step_times: NDArray, + time_per_sample: float, device_batch_size: int, + time_to_first_batch: float, min_cache_limit: int, + cache_limit: Optional[int]): + all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ + get_simulation_stats(step_times, time_per_sample, device_batch_size) + with component.container(): + st.write(f"Minimum cache limit needed: **{min_cache_limit:,} bytes**") + if cache_limit is not None and cache_limit < min_cache_limit: + # Cache limit is too low, and will cause shard redownloads / throughput drops. + st.warning('The provided cache limit is lower than the minimum cache limit needed to \ + prevent shard re-downloads. This can cause throughput issues.', + icon="⚠️") + if warmup_step == total_batches: + # display error if the warmup phase is the whole run, + # meaning that we never hit peak throughput. + st.error('This configuration is severely bottlenecked by downloading. \ + The run will not be performant.', icon="🚨") + elif post_warmup_throughput_drops: + # display warning if post-warmup throughput drops are more than 10% of the run. + st.warning('This configuration experiences some downloading-related slowdowns \ + even after warmup.', icon="⚠️") + st.write("**{0} steps**, or **{1:.1f}%** of all steps, waited for \ + shard downloads.".format(all_throughput_drops, + 100*all_throughput_drops/(total_batches))) + if warmup_step != total_batches: + # only display post-warmup throughput drop info if we actually ended the warmup period + # (i.e. we hit peak throughput at some point) + st.write("There were **{} steps** that waited for shard downloads after the warmup \ + period.".format(post_warmup_throughput_drops)) + st.write("Estimated time to first batch: **{0:.2f} s**".format(time_to_first_batch)) + st.write("Estimated warmup time: **{0:.2f} s**".format(warmup_time)) + +def get_shuffle_quality_chart(data): + bars = ( + alt.Chart(data, title="Shuffle Quality") + .mark_bar() + .encode( + x="algo", + y="quality", + tooltip="quality" + ) + .properties( + width=550, + ) + ) + + return bars.interactive() + + +def display_shuffle_quality_graph(futures, component): + # Retrieve shuffle quality result since it is available + shuffle_algos_qualities = list(zip(*[f.result() for f in futures])) + shuffle_algos = list(shuffle_algos_qualities[0]) + shuffle_qualities = list(shuffle_algos_qualities[1]) + shuffle_quality_df = pd.DataFrame({"algo": shuffle_algos, + "quality": shuffle_qualities}) + component.altair_chart(get_shuffle_quality_chart(shuffle_quality_df), + use_container_width=True) \ No newline at end of file From c9f024d94a0bbc4a4c5632ececdd3479fb0e7ec7 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 3 Oct 2023 16:07:26 -0700 Subject: [PATCH 16/31] add file info strings --- Makefile | 2 +- {simulator => simulation}/README.md | 0 .../core/create_index.py | 4 +- .../core/last_used_ordered_set.py | 0 {simulator => simulation}/core/main.py | 2 +- .../core/node_tracker.py | 1 - .../core/shard_downloads.py | 2 +- .../core/shuffle_quality.py | 9 +-- {simulator => simulation}/core/sim_time.py | 2 +- .../core/simulation_dataset.py | 3 + simulation/core/simulation_spanner.py | 37 ++++++++++ .../core/simulation_world.py | 4 +- {simulator => simulation}/core/utils.py | 0 .../core/yaml_processing.py | 0 {simulator => simulation}/imgs/downloads.png | Bin {simulator => simulation}/imgs/inputs.png | Bin .../imgs/shuffle_quality_graph.png | Bin .../imgs/shuffle_quality_toggle.png | Bin {simulator => simulation}/imgs/stats.png | Bin {simulator => simulation}/imgs/throughput.png | Bin .../imgs/yaml_toggle.png | Bin .../interfaces}/interface_utils.py | 5 ++ .../interfaces}/simcli.py | 2 +- .../interfaces}/simulation_script.py | 4 +- .../interfaces}/simulation_ui.py | 6 +- .../interfaces}/widgets.py | 7 +- {simulator => simulation}/requirements.txt | 0 .../testing}/simulation_testing.py | 5 +- simulator/core/simulation_spanner.py | 66 ------------------ 29 files changed, 72 insertions(+), 89 deletions(-) rename {simulator => simulation}/README.md (100%) rename {simulator => simulation}/core/create_index.py (95%) rename {simulator => simulation}/core/last_used_ordered_set.py (100%) rename {simulator => simulation}/core/main.py (99%) rename {simulator => simulation}/core/node_tracker.py (99%) rename {simulator => simulation}/core/shard_downloads.py (98%) rename {simulator => simulation}/core/shuffle_quality.py (98%) rename {simulator => simulation}/core/sim_time.py (99%) rename {simulator => simulation}/core/simulation_dataset.py (99%) create mode 100644 simulation/core/simulation_spanner.py rename {simulator => simulation}/core/simulation_world.py (94%) rename {simulator => simulation}/core/utils.py (100%) rename {simulator => simulation}/core/yaml_processing.py (100%) rename {simulator => simulation}/imgs/downloads.png (100%) rename {simulator => simulation}/imgs/inputs.png (100%) rename {simulator => simulation}/imgs/shuffle_quality_graph.png (100%) rename {simulator => simulation}/imgs/shuffle_quality_toggle.png (100%) rename {simulator => simulation}/imgs/stats.png (100%) rename {simulator => simulation}/imgs/throughput.png (100%) rename {simulator => simulation}/imgs/yaml_toggle.png (100%) rename {simulator/simulation => simulation/interfaces}/interface_utils.py (98%) rename {simulator/simulation => simulation/interfaces}/simcli.py (98%) rename {simulator/simulation => simulation/interfaces}/simulation_script.py (96%) rename {simulator/simulation => simulation/interfaces}/simulation_ui.py (98%) rename {simulator/simulation => simulation/interfaces}/widgets.py (99%) rename {simulator => simulation}/requirements.txt (100%) rename {simulator/simulation => simulation/testing}/simulation_testing.py (97%) delete mode 100644 simulator/core/simulation_spanner.py diff --git a/Makefile b/Makefile index 278ba6e66..b3a294311 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,6 @@ web: uvicorn scripts.partition.web:app --port 1337 --reload simulator: - streamlit run simulator/simulation/simulation_ui.py + streamlit run simulation/interfaces/simulation_ui.py .PHONY: test lint style diff --git a/simulator/README.md b/simulation/README.md similarity index 100% rename from simulator/README.md rename to simulation/README.md diff --git a/simulator/core/create_index.py b/simulation/core/create_index.py similarity index 95% rename from simulator/core/create_index.py rename to simulation/core/create_index.py index a4584101c..dfc9eba54 100644 --- a/simulator/core/create_index.py +++ b/simulation/core/create_index.py @@ -1,14 +1,14 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Create dataset index file from input parameters.""" +"""Create a dataset index file from input parameters.""" + import os.path import sys sys.path.append(os.path.join(os.path.dirname(__file__), '..')) import json -from core.simulation_dataset import SimulationDataset from streaming.base import Stream from typing import Optional import random diff --git a/simulator/core/last_used_ordered_set.py b/simulation/core/last_used_ordered_set.py similarity index 100% rename from simulator/core/last_used_ordered_set.py rename to simulation/core/last_used_ordered_set.py diff --git a/simulator/core/main.py b/simulation/core/main.py similarity index 99% rename from simulator/core/main.py rename to simulation/core/main.py index c16626953..abe1e24aa 100644 --- a/simulator/core/main.py +++ b/simulation/core/main.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Functions for simulating streaming and displaying results.""" +"""Main simulation function, simulating bytes downloaded and time taken each training step.""" import os.path import sys diff --git a/simulator/core/node_tracker.py b/simulation/core/node_tracker.py similarity index 99% rename from simulator/core/node_tracker.py rename to simulation/core/node_tracker.py index 0e2f93be4..32397322f 100644 --- a/simulator/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -13,7 +13,6 @@ from numpy.typing import NDArray from sortedcollections import OrderedSet from streaming.base.spanner import Spanner -import numpy as np from typing import Optional, Tuple class NodeTracker(): diff --git a/simulator/core/shard_downloads.py b/simulation/core/shard_downloads.py similarity index 98% rename from simulator/core/shard_downloads.py rename to simulation/core/shard_downloads.py index 834f8875a..8969a7567 100644 --- a/simulator/core/shard_downloads.py +++ b/simulation/core/shard_downloads.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Functions for simulating shard downloads.""" +"""Functions for simulating shard downloads and calculating needed cache limit for downloads.""" import os.path import sys diff --git a/simulator/core/shuffle_quality.py b/simulation/core/shuffle_quality.py similarity index 98% rename from simulator/core/shuffle_quality.py rename to simulation/core/shuffle_quality.py index 6af4ff1c4..22bbc4ce2 100644 --- a/simulator/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -3,15 +3,16 @@ """Determine shuffle quality of a run over a fixed number of samples.""" +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + import numpy as np from streaming.base.partition.orig import get_partitions_orig from streaming.base.shuffle import get_shuffle -import matplotlib.pyplot as plt from numpy.typing import NDArray from core.utils import remove_padded_samples -import math -import os -import time def get_entropy(ordering): # get differences between elements diff --git a/simulator/core/sim_time.py b/simulation/core/sim_time.py similarity index 99% rename from simulator/core/sim_time.py rename to simulation/core/sim_time.py index 6c8e3ae06..d36943684 100644 --- a/simulator/core/sim_time.py +++ b/simulation/core/sim_time.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""simulator time classes straight copied from MosaicML composer.""" +"""Time classes ported from MosaicML composer. Avoids dependency on composer and its many reqs.""" from __future__ import annotations import datetime diff --git a/simulator/core/simulation_dataset.py b/simulation/core/simulation_dataset.py similarity index 99% rename from simulator/core/simulation_dataset.py rename to simulation/core/simulation_dataset.py index ab71bed4e..7e320bdf4 100644 --- a/simulator/core/simulation_dataset.py +++ b/simulation/core/simulation_dataset.py @@ -1,5 +1,8 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 + +"""Near replica of StreamingDataset for simulation purposes.""" + import os.path import sys diff --git a/simulation/core/simulation_spanner.py b/simulation/core/simulation_spanner.py new file mode 100644 index 000000000..27da9ea96 --- /dev/null +++ b/simulation/core/simulation_spanner.py @@ -0,0 +1,37 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Mapping of global sample index to shard index for simulation purposes.""" + +from typing import Tuple +from streaming.base.spanner import Spanner + + +class SimulationSpanner(Spanner): + """Given a list of shards, construct a mapping of global index to shard index. + + Args: + shard_sizes (NDArray[np.int64]): Number of samples in each shard. + span_size (int): Size of the divisions of the sample space. Defaults to ``1 << 10``. + """ + + def __getitem__(self, index: int) -> Tuple[int, int]: + """Map global sample index to shard index only. + + Args: + index (int): Global sample index. + + Returns: + int: Shard index of sample. + """ + if not (0 <= index < self.num_samples): + raise ValueError(f'Invalid sample index `{index}`: 0 <= {index} < {self.num_samples}') + + span = index // self.span_size + for shard in self.spans[span]: + shard_start = self.shard_bounds[shard] + shard_stop = self.shard_bounds[shard + 1] + if shard_start <= index < shard_stop: + return shard + + raise RuntimeError('Internal error: shards were indexed incorrectly') diff --git a/simulator/core/simulation_world.py b/simulation/core/simulation_world.py similarity index 94% rename from simulator/core/simulation_world.py rename to simulation/core/simulation_world.py index 84d1865ef..e37754821 100644 --- a/simulator/core/simulation_world.py +++ b/simulation/core/simulation_world.py @@ -1,9 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -import os.path -import sys -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +"""Contains info about the nodes, ranks, and workers of the run for simulation purposes.""" from streaming.base.world import World diff --git a/simulator/core/utils.py b/simulation/core/utils.py similarity index 100% rename from simulator/core/utils.py rename to simulation/core/utils.py diff --git a/simulator/core/yaml_processing.py b/simulation/core/yaml_processing.py similarity index 100% rename from simulator/core/yaml_processing.py rename to simulation/core/yaml_processing.py diff --git a/simulator/imgs/downloads.png b/simulation/imgs/downloads.png similarity index 100% rename from simulator/imgs/downloads.png rename to simulation/imgs/downloads.png diff --git a/simulator/imgs/inputs.png b/simulation/imgs/inputs.png similarity index 100% rename from simulator/imgs/inputs.png rename to simulation/imgs/inputs.png diff --git a/simulator/imgs/shuffle_quality_graph.png b/simulation/imgs/shuffle_quality_graph.png similarity index 100% rename from simulator/imgs/shuffle_quality_graph.png rename to simulation/imgs/shuffle_quality_graph.png diff --git a/simulator/imgs/shuffle_quality_toggle.png b/simulation/imgs/shuffle_quality_toggle.png similarity index 100% rename from simulator/imgs/shuffle_quality_toggle.png rename to simulation/imgs/shuffle_quality_toggle.png diff --git a/simulator/imgs/stats.png b/simulation/imgs/stats.png similarity index 100% rename from simulator/imgs/stats.png rename to simulation/imgs/stats.png diff --git a/simulator/imgs/throughput.png b/simulation/imgs/throughput.png similarity index 100% rename from simulator/imgs/throughput.png rename to simulation/imgs/throughput.png diff --git a/simulator/imgs/yaml_toggle.png b/simulation/imgs/yaml_toggle.png similarity index 100% rename from simulator/imgs/yaml_toggle.png rename to simulation/imgs/yaml_toggle.png diff --git a/simulator/simulation/interface_utils.py b/simulation/interfaces/interface_utils.py similarity index 98% rename from simulator/simulation/interface_utils.py rename to simulation/interfaces/interface_utils.py index cb1a65b40..70b9608e8 100644 --- a/simulator/simulation/interface_utils.py +++ b/simulation/interfaces/interface_utils.py @@ -3,6 +3,11 @@ """Peripheral functions for interface functionality.""" +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + import numpy as np from numpy.typing import NDArray from omegaconf import DictConfig diff --git a/simulator/simulation/simcli.py b/simulation/interfaces/simcli.py similarity index 98% rename from simulator/simulation/simcli.py rename to simulation/interfaces/simcli.py index 9fe22c33b..ceca7f8c7 100644 --- a/simulator/simulation/simcli.py +++ b/simulation/interfaces/simcli.py @@ -9,7 +9,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from core.main import simulate -from interface_utils import plot_simulation +from interfaces.interface_utils import plot_simulation from core.utils import get_simulation_stats import argparse from core.yaml_processing import ingest_yaml, create_simulation_dataset diff --git a/simulator/simulation/simulation_script.py b/simulation/interfaces/simulation_script.py similarity index 96% rename from simulator/simulation/simulation_script.py rename to simulation/interfaces/simulation_script.py index 164b1a3db..1d7c164ea 100644 --- a/simulator/simulation/simulation_script.py +++ b/simulation/interfaces/simulation_script.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Script for simulating streaming and displaying results.""" +"""Script for simulating training downloads and throughput, and displaying results.""" import os.path import sys @@ -16,7 +16,7 @@ import matplotlib.pyplot as plt import numpy as np from streaming.base import Stream -from interface_utils import plot_simulation +from interfaces.interface_utils import plot_simulation # Input Parameters diff --git a/simulator/simulation/simulation_ui.py b/simulation/interfaces/simulation_ui.py similarity index 98% rename from simulator/simulation/simulation_ui.py rename to simulation/interfaces/simulation_ui.py index 1bf785f4c..915e2b02b 100644 --- a/simulator/simulation/simulation_ui.py +++ b/simulation/interfaces/simulation_ui.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""simulator web UI using streamlit.""" +"""Simulator web UI using streamlit.""" import os.path import sys @@ -19,8 +19,8 @@ from core.yaml_processing import ingest_yaml, create_simulation_dataset from core.create_index import create_stream_index from core.shuffle_quality import analyze_shuffle_quality -from interface_utils import get_train_dataset_params -from widgets import param_inputs, get_line_chart, get_shuffle_quality_chart,\ +from interfaces.interface_utils import get_train_dataset_params +from interfaces.widgets import param_inputs, get_line_chart, get_shuffle_quality_chart,\ display_simulation_stats, display_shuffle_quality_graph import yaml from typing import Optional, Union diff --git a/simulator/simulation/widgets.py b/simulation/interfaces/widgets.py similarity index 99% rename from simulator/simulation/widgets.py rename to simulation/interfaces/widgets.py index 6081cef13..adcd1e988 100644 --- a/simulator/simulation/widgets.py +++ b/simulation/interfaces/widgets.py @@ -1,7 +1,12 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Streamlit UI Widgets.""" +"""Streamlit widgets for simulation web UI.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) import altair as alt from streaming.base.util import bytes_to_int diff --git a/simulator/requirements.txt b/simulation/requirements.txt similarity index 100% rename from simulator/requirements.txt rename to simulation/requirements.txt diff --git a/simulator/simulation/simulation_testing.py b/simulation/testing/simulation_testing.py similarity index 97% rename from simulator/simulation/simulation_testing.py rename to simulation/testing/simulation_testing.py index 2ac53fb08..b324d55ec 100644 --- a/simulator/simulation/simulation_testing.py +++ b/simulation/testing/simulation_testing.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Test simulation results against run results from wandb.""" +"""Test simulation results against run results from a wandb project.""" import os.path import sys @@ -26,7 +26,8 @@ project_runs_list = [run.id for run in project_runs] skip = 0 -# C4 neox compressed from OCI parameters +# Enter the dataset parameters here. +# These are the params for C4 dataset, gpt-neox tokenized. shards = 20850 samples_per_shard = 4093 avg_raw_shard_size = 67092639 diff --git a/simulator/core/simulation_spanner.py b/simulator/core/simulation_spanner.py deleted file mode 100644 index 86b2bf8b3..000000000 --- a/simulator/core/simulation_spanner.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Mapping of global sample index to shard and relative sample index.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -from typing import Tuple -from streaming.base.spanner import Spanner - -import numpy as np -from numpy.typing import NDArray - - -class SimulationSpanner(Spanner): - """Given a list of shards, construct a mapping of global index to shard index. - - Args: - shard_sizes (NDArray[np.int64]): Number of samples in each shard. - span_size (int): Size of the divisions of the sample space. Defaults to ``1 << 10``. - """ - - def __init__(self, shard_sizes: NDArray[np.int64], span_size: int = 1 << 10) -> None: - self.shard_sizes = shard_sizes - self.span_size = span_size - self.num_samples = sum(shard_sizes) - self.shard_bounds = np.concatenate([np.zeros(1, np.int64), shard_sizes.cumsum()]) - - overflow = self.num_samples % span_size - underflow = span_size - overflow if overflow else 0 - self.shard_sizes[-1] += underflow - - sample_shards = np.repeat(np.arange(len(shard_sizes)), self.shard_sizes) - sample_shards = sample_shards.reshape(-1, span_size) - span_lowest_shards = sample_shards.min(1) - span_highest_shards = sample_shards.max(1) - - self.spans = [] - for low, high in zip(span_lowest_shards, span_highest_shards): - shards = np.arange(low, high + 1) - self.spans.append(shards) - - self.shard_sizes[-1] -= underflow - - def __getitem__(self, index: int) -> Tuple[int, int]: - """Map global sample index to shard and relative sample index. - - Args: - index (int): Global sample index. - - Returns: - int: Shard index of sample. - """ - if not (0 <= index < self.num_samples): - raise ValueError(f'Invalid sample index `{index}`: 0 <= {index} < {self.num_samples}') - - span = index // self.span_size - for shard in self.spans[span]: - shard_start = self.shard_bounds[shard] - shard_stop = self.shard_bounds[shard + 1] - if shard_start <= index < shard_stop: - return shard - - raise RuntimeError('Internal error: shards were indexed incorrectly') From 002b9f817fa4bd04847c2955318dd1ce9d53f688 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 3 Oct 2023 18:31:11 -0700 Subject: [PATCH 17/31] fixing docstrings and typing for core functions --- simulation/core/create_index.py | 2 + simulation/core/last_used_ordered_set.py | 10 ++--- simulation/core/main.py | 52 ++++++++++++------------ simulation/core/node_tracker.py | 41 +++++++++++-------- simulation/core/shard_downloads.py | 47 +++++++++++++++------ simulation/core/shuffle_quality.py | 22 ++++++---- simulation/core/simulation_dataset.py | 37 ++++++++--------- simulation/core/simulation_world.py | 9 +++- simulation/core/utils.py | 15 ++++++- simulation/core/yaml_processing.py | 34 +++++++++++----- simulation/interfaces/interface_utils.py | 13 ++---- simulation/interfaces/widgets.py | 2 +- 12 files changed, 171 insertions(+), 113 deletions(-) diff --git a/simulation/core/create_index.py b/simulation/core/create_index.py index dfc9eba54..b22906252 100644 --- a/simulation/core/create_index.py +++ b/simulation/core/create_index.py @@ -15,6 +15,8 @@ import string def get_random_foldername(): + """Generate random folder name to store the index file in.""" + return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(16)) def create_stream_index(shards: int, diff --git a/simulation/core/last_used_ordered_set.py b/simulation/core/last_used_ordered_set.py index 9dff38435..ba3a7d1cf 100644 --- a/simulation/core/last_used_ordered_set.py +++ b/simulation/core/last_used_ordered_set.py @@ -6,11 +6,8 @@ from collections import OrderedDict from typing import Any -# custom ordered dictionary with setitem method to move items to the end of the dictionary if they are accessed - - class LastUsedOrderedSet(OrderedDict): - """An ordered set that can be used as an LRU cache. + """An ordered dict that can be used as an LRU cache. This is a subclass of OrderedDict, with some LRU-specific functions and all values as ``None``. """ @@ -19,8 +16,9 @@ def setitem(self, key: Any, move_to_end: bool = True): """Set/add an item. Args: - key (Any): add a key. - move_to_end (bool, optional): whether to move the item to the end, signifying most recent access. Defaults to ``True``. + key (Any): key to be added. + move_to_end (bool, optional): whether to move the item to the end, signifying most + recent access. Defaults to ``True``. """ super().__setitem__(key, None) self.move_to_end(key, last=move_to_end) diff --git a/simulation/core/main.py b/simulation/core/main.py index abe1e24aa..0a52e578f 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -13,7 +13,7 @@ from numpy.typing import NDArray from core.shard_downloads import simulate_shard_downloads, run_cache_limit from core.sim_time import Time -from typing import Tuple, Union +from typing import Union import time from core.simulation_dataset import SimulationDataset @@ -25,23 +25,28 @@ def simulate(dataset: SimulationDataset, node_network_bandwidth: Union[float,str], generator: bool = False, max_duration: Time = None - ) -> Union[Tuple[int, float, int], - Tuple[NDArray, NDArray, float, int], - Tuple[float, int]]: + ) -> Union[tuple[int, float, int], + tuple[float, int], + tuple[NDArray, NDArray, float, int]]: """Simulates step time and downloads using streaming for the specified input parameters. - Key Notes and Assumptions: + At each training step, the simulation does the following: + * gets the shards containing the current batch's samples. + * for each node, downloads shards if they are not present (round-robin through workers). + * predownloads more shards during model process time plus extra time from previous step. + * tracks the time the the step took as well as downloaded bytes. - * assume that batch time is solely made up of two things: batch processing time and batch - shard download wait time - * loop through workers round-robin style for batches and for downloads - * assume each node has a separate network bandwidth + Key Notes and Assumptions: + * assume that batch time is solely made up of two things: batch shard download wait time + and batch processing time + * loop through workers in a node round-robin style for batches and for downloads + * assume each node has a separate, uniform network bandwidth * the batch has to wait until all nodes have downloaded the shards containing batch samples. * for shard eviction itself, use LRU shard eviction to take out the least recently used shard, per node. * shards are shared across devices on a single node, but nodes do not share shards between each other. - * if a shard is unavailable, we wait for some worker to download it. + * if a shard is unavailable, we download shards round-robin until we have it. * if a shard is available in a node, we just use it. Args: @@ -52,8 +57,11 @@ def simulate(dataset: SimulationDataset, max_duration (Time, optional): max duration of simulation. Defaults to ``None``. Returns: - Union[Tuple[int, int], Tuple[NDArray, NDArray], np.float64]: either a Tuple of step_time, - shard_download, or a Tuple all step_times, shard_downloads or the startup time. + Union[tuple[int, float, int], + tuple[NDArray, NDArray, float, int], + tuple[float, int]]: either a tuple of step number, step time, and downloaded bytes, + a tuple of startup time and min needed cache limit, (both when generator=True), or a + tuple of all step times, downloaded bytes, startup_time, and min needed cache limit. """ # tracking startup time, which includes SimulationDataset instantiation time. @@ -76,15 +84,12 @@ def simulate(dataset: SimulationDataset, # dataset's spanner object maps global sample id to shard id. sample_to_shard = dataset.get_spanner() - # track shard access ranges to compute minimum needed cache limit for the run. - shard_access_starts = np.full(total_shards, -1) - shard_access_ends = np.full(total_shards, -1) - # Initialize NodeTracker objects for each node. These keep track of shards, worker downloads, - # cache usage, etc. for each node. + # cache usage, shard usage ranges, etc. for each node. nodes = [] for _ in range(physical_nodes): - nodes.append(NodeTracker(workers, devices, predownload, device_batch_size, cache_limit)) + nodes.append(NodeTracker(workers, devices, predownload, device_batch_size, + total_shards, cache_limit)) # Time for the global batch is just device batch size * time per sample. # We assume all devices process their microbatch perfectly in parallel. @@ -144,7 +149,7 @@ def simulate(dataset: SimulationDataset, worker_sample_index, sample_to_shard) # Mark all shards present as accessed most recently in this node. - node.set_shards_used(shards_present, shard_access_ends, step_num) + node.set_shards_used(shards_present, step_num) # Push the predownload for the current batch workers ahead by device_batch_size. node.update_worker_predownloads(curr_worker, worker_sample_index, sample_to_shard) # Track bytes downloaded by this node. @@ -158,8 +163,6 @@ def simulate(dataset: SimulationDataset, raw_shard_sizes, zip_shard_sizes, current_batch_downloads=True, - shard_access_starts=shard_access_starts, - shard_access_ends=shard_access_ends, step_num=step_num, cache_limit=cache_limit, shards_needed=shards_needed) @@ -202,8 +205,6 @@ def simulate(dataset: SimulationDataset, raw_shard_sizes, zip_shard_sizes, current_batch_downloads=False, - shard_access_starts=shard_access_starts, - shard_access_ends=shard_access_ends, step_num=step_num, cache_limit=cache_limit, download_bytes_left=download_bytes_left) @@ -230,9 +231,8 @@ def simulate(dataset: SimulationDataset, if curr_worker == workers - 1: worker_sample_index += device_batch_size - # Simulation is finished. Calculate needed cache limit from shard access ranges. - min_cache_limit = run_cache_limit(shard_access_starts, shard_access_ends, - raw_shard_sizes, nodes) + # Simulation is finished. Calculate needed cache limit. + min_cache_limit = run_cache_limit(nodes, raw_shard_sizes) # Yield results. if not generator: diff --git a/simulation/core/node_tracker.py b/simulation/core/node_tracker.py index 32397322f..e74cc3983 100644 --- a/simulation/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -13,21 +13,21 @@ from numpy.typing import NDArray from sortedcollections import OrderedSet from streaming.base.spanner import Spanner -from typing import Optional, Tuple +from typing import Optional +import numpy as np class NodeTracker(): - def __init__(self, workers: int, devices: int, predownload: int, - device_batch_size: int, cache_limit: Optional[int] = None): + def __init__(self, workers: int, devices: int, predownload: int, device_batch_size: int, + total_shards: int, cache_limit: Optional[int] = None): """Tracker for node information during simulation. Args: - node_id (int): The node ID. workers (int): The number of workers. devices (int): The number of devices. predownload (int): The number of samples to predownload. device_batch_size (int): The device batch size. - sample_to_shard (Spanner): The mapping from samples to shards. + total_shards (int): Total number of shards in the dataset. cache_limit (Optional[int]): The cache limit for the node. Defaults to None. """ self.shards = LastUsedOrderedSet() @@ -42,12 +42,18 @@ def __init__(self, workers: int, devices: int, predownload: int, self.predownload = predownload self.cache_limit = cache_limit self.worker_downloads = [] + self.shard_access_starts = np.full(total_shards, -1) + self.shard_access_ends = np.full(total_shards, -1) # Use the set_epoch_samples method every epoch to set the node's samples. self.samples = None def initialize_worker_downloads(self, sample_to_shard: Spanner): - """Initialize the worker downloads.""" + """Initialize worker downloads, making shards in the predownload sample range available. + + Args: + sample_to_shard (Spanner): The mapping from samples to shards. + """ # For downloads, we round-robin over devices first, then workers. if self.samples is None: raise ValueError("Must set samples before initializing worker downloads.") @@ -61,14 +67,11 @@ def initialize_worker_downloads(self, sample_to_shard: Spanner): for sample in download_samples]) self.worker_downloads.append(download_shards) - def set_shards_used(self, shards: set, - shard_access_ends: NDArray, - step_num: int): - """Set a set of shards as used. + def set_shards_used(self, shards: set, step_num: int): + """Mark a set of shards as recently used. Args: shards (set): The shards to set as used. - shard_access_ends (NDArray): The shard access end steps. step_num (int): The current step number. """ for shard in shards: @@ -77,7 +80,7 @@ def set_shards_used(self, shards: set, # at least the next step begins. Adding 0.5 ensures that we evict shards # after they are used for the last time, but before they are replaced by # new downloads in the next step. - shard_access_ends[shard] = step_num + 0.5 + self.shard_access_ends[shard] = step_num + 0.5 def add_shard(self, shard: int, used: bool = True): """Add a shard to the node. @@ -109,6 +112,7 @@ def evict_until_satisfied(self, incoming_shard_size: int, raw_shard_sizes: NDArr incoming_shard_size (int): The size of the incoming shard. raw_shard_sizes (NDArray): The raw shard sizes. """ + # We evict shards until the incoming shard fits into the node's cache. while self.cache_usage + incoming_shard_size > self.cache_limit: evicted_shard = self.evict_shard() self.cache_usage -= raw_shard_sizes[evicted_shard] @@ -132,15 +136,17 @@ def get_worker_download(self, OrderedSet: The shard downloads, in order, for this worker. """ if index is not None: + # Directly access worker_downloads through an index. return self.worker_downloads[index] elif worker is not None and device is not None: + # Access worker_downloads through worker and device indices. return self.worker_downloads[worker * self.devices + device] else: raise ValueError("Must specify either index, or worker and device.") def get_current_batch_shards(self, worker: int, worker_sample_index: int, - sample_to_shard: Spanner) -> Tuple[set, set]: + sample_to_shard: Spanner) -> tuple[set, set]: """Get this node's shards for the current batch. Args: @@ -148,9 +154,9 @@ def get_current_batch_shards(self, worker: int, worker_sample_index (int): The worker sample index. sample_to_shard (Spanner): The mapping from samples to shards. Returns: - Tuple[set, set]: shard ids needed by node, shard ids present in node. + tuple[set, set]: shard ids needed by node, shard ids present in node. """ - batch_samples = remove_padded_samples(self.samples[:, worker, + batch_samples = remove_padded_samples(self.samples[:, worker, worker_sample_index: worker_sample_index + self.device_batch_size].flatten()) batch_shards = set([sample_to_shard[sample] for sample in batch_samples]) @@ -181,7 +187,7 @@ def get_next_worker_with_downloads(self) -> Optional[OrderedSet]: def update_worker_predownloads(self, worker: int, worker_sample_index: int, sample_to_shard: Spanner): - """Get the worker predownload samples for a worker and device. + """Update the worker predownload samples for a worker and device. Args: worker (int): The current batch worker index. @@ -189,6 +195,7 @@ def update_worker_predownloads(self, worker: int, sample_to_shard (Spanner): The mapping from samples to shards. """ for device in range(self.devices): + # Retrieve new samples that are now within predownload range of the worker. new_download_samples = remove_padded_samples(self.samples[device, worker, worker_sample_index + self.predownload: worker_sample_index + self.device_batch_size + @@ -200,7 +207,7 @@ def update_worker_predownloads(self, worker: int, worker_downloads = self.get_worker_download(worker=worker, device=device) - # Add in new shards to the worker's shard downloads only if the node does not yet have it. + # Add in new shards to the worker's shard downloads only if node does not yet have it. for shard in new_download_shards: if shard not in self.shards: worker_downloads.add(shard) diff --git a/simulation/core/shard_downloads.py b/simulation/core/shard_downloads.py index 8969a7567..b96b71811 100644 --- a/simulation/core/shard_downloads.py +++ b/simulation/core/shard_downloads.py @@ -10,18 +10,33 @@ from core.node_tracker import NodeTracker from numpy.typing import NDArray -from typing import Optional, Tuple +from typing import Optional def simulate_shard_downloads(node: NodeTracker, raw_shard_sizes: NDArray, zip_shard_sizes: NDArray, current_batch_downloads: bool, - shard_access_starts: NDArray, - shard_access_ends: NDArray, step_num: int, cache_limit: Optional[int] = None, shards_needed: Optional[set] = None, - download_bytes_left: Optional[int] = None) -> Tuple[bool, int]: + download_bytes_left: Optional[int] = None) -> tuple[bool, int]: + """Simulate downloading a shard for a node. + + Args: + node (NodeTracker): The node to simulate downloading a shard for. + raw_shard_sizes (NDArray): The raw sizes of all shards. + zip_shard_sizes (NDArray): The zip sizes of all shards. + current_batch_downloads (bool): Whether we are downloading shards for the current batch. + step_num (int): The current step number. + cache_limit (Optional[int]): The cache limit for the node. Defaults to ``None``. + shards_needed (Optional[set]): The shards needed for the current batch. + Defaults to ``None``. + download_bytes_left (Optional[int]): The number of download bytes left in the downloading + time interval. Defaults to ``None``. + + Returns: + tuple[bool, int]: A tuple of the shard download status and the download size. + """ worker_download = node.get_next_worker_with_downloads() if worker_download is None: @@ -69,14 +84,15 @@ def simulate_shard_downloads(node: NodeTracker, else: node.add_shard(download_shard) - if shard_access_starts[download_shard] == -1: + + if node.shard_access_starts[download_shard] == -1: # Shard has never been accessed before. Set its access start. - shard_access_starts[download_shard] = step_num + node.shard_access_starts[download_shard] = step_num # For any shard access, we are accessing the shard so we need the shard until # at least the next step begins. Adding 0.5 ensures that we evict shards # after they are used for the last time, but before they are replaced by # new downloads in the next step. - shard_access_ends[download_shard] = step_num + 0.5 + node.shard_access_ends[download_shard] = step_num + 0.5 # Advance the worker download index. node.increment_worker_download_index() @@ -97,11 +113,16 @@ def simulate_shard_downloads(node: NodeTracker, worker_download.pop() return ("present", 0) -def run_cache_limit(shard_access_starts: NDArray, - shard_access_ends: NDArray, - raw_shard_sizes: NDArray, - nodes: list[NodeTracker]) -> int: +def run_cache_limit(nodes: list[NodeTracker], raw_shard_sizes: NDArray) -> int: + """Find the minimum needed cache limit across all nodes for this run. + + Args: + nodes (list[NodeTracker]): The nodes, which contain shard use information. + raw_shard_sizes (NDArray): The raw sizes of all shards. + Returns: + int: The minimum needed cache limit, in bytes, for the run. + """ # Find the overall needed cache usage, as the max needed for any node at any point. needed_cache_usage = 0 for node in nodes: @@ -110,8 +131,8 @@ def run_cache_limit(shard_access_starts: NDArray, # Event types: 0 means a shard has been accessed, 1 means a shard has ended access. node_shards = node.get_all_shards() access_events = [] - access_events += [(shard_access_starts[i], i, 0) for i in node_shards] - access_events += [(shard_access_ends[i], i, 1) for i in node_shards] + access_events += [(node.shard_access_starts[i], i, 0) for i in node_shards] + access_events += [(node.shard_access_ends[i], i, 1) for i in node_shards] # Sort the access events to get shard events, in order. access_events.sort(key=lambda x: x[0]) diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index 22bbc4ce2..7dcdbf95b 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -14,7 +14,15 @@ from numpy.typing import NDArray from core.utils import remove_padded_samples -def get_entropy(ordering): +def get_entropy(ordering: NDArray) -> float: + """Calculate the entropy of an ordering, which is initially assumed to be in ascending order. + + Args: + ordering (NDArray): The ordering to calculate the entropy of. + + Returns: + float: The entropy of the ordering. + """ # get differences between elements diffs = np.diff(ordering) # diffs = np.insert(diffs, ordering.shape[0]-1, ordering[0]-ordering[-1]) @@ -43,7 +51,7 @@ def get_partition_shard_info(epoch_size: int, workers: int, device_batch_size: int, samples_per_shard: int) -> tuple[NDArray, NDArray, NDArray]: - """Get a partition for a shuffle. + """Partition up to 100 million samples and get associated shard information. Args: epoch_size (int): The number of samples in an epoch. @@ -61,7 +69,7 @@ def get_partition_shard_info(epoch_size: int, num_samples = epoch_size if num_samples > 100000000: - print("Epoch size is >100 million. Using 100 million samples for shuffle quality analysis.") + print("Epoch size is >100 million. Using 100 million samples to analyze shuffle quality.") num_samples = 100000000 partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, @@ -90,7 +98,7 @@ def get_entropy_shuffle_quality(shuffle_algo: str, canonical_nodes: int, seed: int, shuffle_block_size: int) -> float: - """Evaluate the entropy of a shuffle algorithm. + """Get the entropy of a shuffle, assuming samples and shards were initially in ascending order. Args: shuffle_algo (str): The shuffle algorithm to use. @@ -102,7 +110,7 @@ def get_entropy_shuffle_quality(shuffle_algo: str, shuffle_block_size (int): The shuffle block size. Returns: - float: The entropy of the shuffle for the first NCN*SBS samples. + float: The entropy of the shuffle, combining entropy from sample and shard orderings. """ if shuffle_algo != 'none': @@ -123,7 +131,7 @@ def analyze_all_shuffle_quality(algos: list[str], shuffle_block_size: int, samples_per_shard: int, epoch_size: int, - seed: int): + seed: int) -> list[tuple[str, float]]: """Analyze the quality of this shuffle across algorithms. Args: @@ -168,7 +176,7 @@ def analyze_shuffle_quality(algo: str, shuffle_block_size: int, samples_per_shard: int, epoch_size: int, - seed: int): + seed: int) -> tuple[str, float]: """Analyze the quality of a shuffle for one algorithm. Args: diff --git a/simulation/core/simulation_dataset.py b/simulation/core/simulation_dataset.py index 7e320bdf4..dac2c65b8 100644 --- a/simulation/core/simulation_dataset.py +++ b/simulation/core/simulation_dataset.py @@ -29,21 +29,24 @@ class SimulationDataset(StreamingDataset): """Near replica of StreamingDataset for simulation purposes. Args: - streams (Sequence[Stream], optional): One or more streams to stream/cache samples from, + nodes (int): Number of nodes. + devices (int): Number of devices. + workers (int): Number of workers. + streams (Optional[Sequence[Stream]]): One or more streams to stream/cache samples from, which may be upsampled or downsampled. StreamingDataset uses either ``streams`` or ``remote``/``local``. Defaults to ``None``. - remote (str, optional): Remote path or directory to download the dataset from. If ``None``, + remote (Optional[str]): Remote path or directory to download the dataset from. If ``None``, its data must exist locally. StreamingDataset uses either ``streams`` or ``remote``/``local``. Defaults to ``None``. - local (str, optional): Local working directory to download shards to. This is where shards + local (Optional[str]): Local working directory to download shards to. This is where shards are cached while they are being used. Uses a temp directory if not set. StreamingDataset uses either ``streams`` or ``remote``/``local``. Defaults to ``None``. - split (str, optional): Which dataset split to use, if any. If provided, we stream from/to + split (Optional[str]): Which dataset split to use, if any. If provided, we stream from/to the ``split`` subdirs of ``remote`` and ``local``. Defaults to ``None``. download_retry (int): Number of download re-attempts before giving up. Defaults to ``2``. download_timeout (float): Number of seconds to wait for a shard to download before raising an exception. Defaults to ``60``. - validate_hash (str, optional): Optional hash or checksum algorithm to use to validate + validate_hash (Optional[str]): Optional hash or checksum algorithm to use to validate shards. Defaults to ``None``. keep_zip (bool): Whether to keep or delete the compressed form when decompressing downloaded shards. If ``False``, keep iff remote is local or no remote. Defaults to @@ -73,11 +76,6 @@ class SimulationDataset(StreamingDataset): StreamingDataset replicas take through the shards per model replica (increasing data source diversity). Defaults to ``None``, which is interpreted as 64 times the number of nodes of the initial run. - - .. note:: - - For sequential sample ordering, set ``shuffle`` to ``False`` and - ``num_canonical_nodes`` to the number of physical nodes of the initial run. batch_size (int, optional): Batch size of its DataLoader, which affects how the dataset is partitioned over the workers. Defaults to ``None``. shuffle (bool): Whether to iterate over the samples in randomized order. Defaults to @@ -87,6 +85,12 @@ class SimulationDataset(StreamingDataset): shuffle_block_size (int): Unit of shuffle. Defaults to ``1 << 18``. sampling_method (str): Which sampling method to use, either ``balanced`` or ``fixed``. Defaults to ``balanced``. + sampling_granularity (int): When picking samples for a stream's final partial repeat, + how many samples to pick from the same shard at a time (``1`` for evenly balanced + across shards, ``1000`` to pick 1000 samples from the same shard at a time, etc). + Defaults to ``1``. + batching_method (str): Which batching method to use, either ``random``, ``stratified``, or + ``per_stream``. Defaults to ``random``. """ def __init__(self, @@ -155,8 +159,7 @@ def __init__(self, ) # Check that predownload is at least per device batch size. - if self.predownload is not None and self.batch_size is not None and \ - self.predownload < self.batch_size: + if self.predownload < self.batch_size: warnings.warn(f'predownload < batch_size ({self.predownload} < {self.batch_size}).' + f'This may result in slower batch time. Recommendation is to set ' + f'predownload to at-least batch_size.') @@ -308,7 +311,7 @@ def __init__(self, print("SimulationDataset created successfully.") - def get_sample_partition(self, epoch, sample_in_epoch) -> NDArray: + def get_sample_partition(self, epoch: int, sample_in_epoch: int) -> NDArray: """Get the dataset's partition of this epoch's sample space. Args: @@ -320,7 +323,7 @@ def get_sample_partition(self, epoch, sample_in_epoch) -> NDArray: """ return generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) - def get_samples_per_node(self, epoch, sample_in_epoch) -> NDArray: + def get_samples_per_node(self, epoch: int, sample_in_epoch: int) -> NDArray: """Get the dataset's number of samples per node, worker, device. Args: @@ -328,7 +331,7 @@ def get_samples_per_node(self, epoch, sample_in_epoch) -> NDArray: sample_in_epoch (int): Where we are in the epoch. Returns: - NDArray[np.int64]: The dataset's number of samples per node, worker, device. + NDArray[np.int64]: The dataset's samples per node, worker, device. """ partition = generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) # Modify partition to be in traversal order, per node, device, and worker. @@ -517,7 +520,3 @@ def get_batching_method(self) -> str: str: The dataset's batching method. """ return self.batching_method - - - - diff --git a/simulation/core/simulation_world.py b/simulation/core/simulation_world.py index e37754821..383febaf3 100644 --- a/simulation/core/simulation_world.py +++ b/simulation/core/simulation_world.py @@ -30,10 +30,17 @@ def __init__(self, nodes: int, devices: int, workers: int): + """Contains info about the nodes, ranks, and workers of the run, for simulation. + + Args: + nodes (int): The number of nodes. + devices (int): The number of devices per node. + workers (int): The number of workers per device. + """ # For simulation purposes, we take in the nodes, devices, and workers from the # SimulationDataset, and assume we are always rank 0 and worker 0. - + self.rank = 0 self.num_ranks = nodes*devices self.ranks_per_node = devices diff --git a/simulation/core/utils.py b/simulation/core/utils.py index 904af96f3..0c1ab58ae 100644 --- a/simulation/core/utils.py +++ b/simulation/core/utils.py @@ -108,13 +108,23 @@ def time_to_bytes(time: float, bandwidth: int) -> int: return int(time * bandwidth) def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray: + """Get rolling average throughput from step times. + + Args: + step_times (NDArray): time per step, as calculated by simulation + window (int): window size for rolling average + + Returns: + NDArray: rolling average throughput + """ step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') batch_throughput_rolling_avg = 1 / step_times_rolling_avg batch_throughput_rolling_avg = np.concatenate((np.array([0] * (window-1)), batch_throughput_rolling_avg)) return batch_throughput_rolling_avg -def get_simulation_stats(step_times, time_per_sample, device_batch_size): +def get_simulation_stats(step_times: NDArray, time_per_sample: float, + device_batch_size: int) -> tuple[int, float, int, int]: """Gets simulation stats for web UI. Args: @@ -123,7 +133,8 @@ def get_simulation_stats(step_times, time_per_sample, device_batch_size): device_batch_size (int): batch size per device Returns: - Tuple[float, float, float]: percent of download-limited steps, warmup time + tuple[int, float, int, int]: number of steps with throughput drops, time till warmup, + step number of warmup, number of steps with throughput drops after warmup """ # calculate percent of download-limited steps diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index 071622185..89e8b1deb 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -8,23 +8,26 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from omegaconf import ListConfig +from omegaconf import DictConfig from omegaconf import OmegaConf as om +from omegaconf import SCMode from core.simulation_dataset import SimulationDataset from typing import Optional from core.sim_time import Time, TimeUnit, ensure_time from streaming.base import Stream -def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None) -> tuple[Optional[int], int, Time, int, dict]: +def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None + ) -> tuple[Optional[int], int, Time, int, dict]: """Create SimulationDataset from yaml file and other needed args. Args: - yaml_dict (Optional[dict]): yaml file, read in as a dictionary + yaml_dict (Optional[dict]): yaml file already converted to a dictionary filepath (Optional[str]): path to yaml file Returns: - tuple[Optional[int], int, Time, int, dict]: total_devices, workers, max_duration, global_batch_size, train_dataset from yaml + tuple[Optional[int], int, Time, int, dict]: total_devices, workers, max_duration, + global_batch_size, train_dataset parameters from yaml """ config = None # Read in the yaml file @@ -97,31 +100,40 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None if time_unit != TimeUnit.EPOCH and time_unit != TimeUnit.BATCH: raise ValueError("Simulator currently only supports max_duration in epochs or batches.") + # convert train_dataset to dictionary from potentially a DictConfig + if isinstance(train_dataset, DictConfig): + train_dataset = om.to_container(train_dataset, + resolve=False, + throw_on_missing=True, + structured_config_mode=SCMode.INSTANTIATE) + return total_devices, workers, max_duration, global_batch_size, train_dataset -def create_simulation_dataset(nodes, devices, workers, global_batch_size, train_dataset) -> SimulationDataset: - """Create SimulationDataset from yaml file and other needed args. +def create_simulation_dataset(nodes: int, devices: int, workers: int, global_batch_size: int, + train_dataset: dict) -> SimulationDataset: + """Create SimulationDataset from input information. Args: nodes (int): number of physical nodes devices (int): number of devices per node workers (int): number of workers per device global_batch_size (int): global batch size (samples) - train_dataset (DictConfig): train_dataset parameters from yaml file - indices_created (bool): whether new indices for streams have already been created + train_dataset (dict): train_dataset parameters from yaml file Returns: - SimulationDataset: simulation dataset + SimulationDataset: SimulationDataset created from input information. """ streams = None # Check for cases where local and remote may be lists and turn those into streams. if 'local' in train_dataset and 'remote' in train_dataset: - if isinstance(train_dataset['local'], ListConfig) and isinstance(train_dataset['remote'], ListConfig): + if isinstance(train_dataset['local'], list) \ + and isinstance(train_dataset['remote'], list): if len(train_dataset['local']) != len(train_dataset['remote']): raise ValueError("local and remote must be the same length in the yaml file.") streams = [] for local, remote in zip(train_dataset['local'], train_dataset['remote']): - streams.append(Stream(local=local, remote=remote, split=train_dataset['split'] if 'split' in train_dataset else None)) + streams.append(Stream(local=local, remote=remote, split=train_dataset['split'] \ + if 'split' in train_dataset else None)) del train_dataset['local'] del train_dataset['remote'] diff --git a/simulation/interfaces/interface_utils.py b/simulation/interfaces/interface_utils.py index 70b9608e8..7a166bf63 100644 --- a/simulation/interfaces/interface_utils.py +++ b/simulation/interfaces/interface_utils.py @@ -10,9 +10,6 @@ import numpy as np from numpy.typing import NDArray -from omegaconf import DictConfig -from omegaconf import OmegaConf as om -from omegaconf import SCMode from typing import Optional from core.utils import get_rolling_avg_throughput from streaming.base.util import number_abbrev_to_int @@ -70,7 +67,8 @@ def plot_simulation(step_times: NDArray, plt.show() -def get_train_dataset_params(input_params: dict, create_indices: bool = False, old_params: Optional[DictConfig] = None) -> DictConfig: +def get_train_dataset_params(input_params: dict, create_indices: bool = False, + old_params: Optional[dict] = None) -> dict: train_dataset_params = {} train_dataset_params["epoch_size"] = input_params["epoch_size"] train_dataset_params["batch_size"] = input_params["device_batch_size"] @@ -94,11 +92,6 @@ def get_train_dataset_params(input_params: dict, create_indices: bool = False, o # If there were old params, fill them in. if old_params is not None: existing_params_set = set(train_dataset_params.keys()) - - old_params = om.to_container(old_params, - resolve=False, - throw_on_missing=True, - structured_config_mode=SCMode.INSTANTIATE) old_params_set = set(old_params.keys()) # Keep params that were set in yaml but not accessible by the user in the UI. # This includes the old_params "local"/"remote" or "streams". @@ -108,4 +101,4 @@ def get_train_dataset_params(input_params: dict, create_indices: bool = False, o # If there are no old params, we need to set streams to what the user provided. train_dataset_params["streams"] = input_params["streams"] - return om.create(train_dataset_params) + return train_dataset_params diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index adcd1e988..369a18a35 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -240,7 +240,7 @@ def param_inputs(col, input_params: dict, defaults: dict = {}): help="shuffling algorithm to use for this run. your shuffle \ parameters may affect model training.") shuffle_block_size = col5.text_input('shuffle block size (samples)', - value="10M" if "shuffle_block_size" not in defaults + value="2M" if "shuffle_block_size" not in defaults else defaults["shuffle_block_size"], help="shuffle block size for this run. \ used in the `py1b`, `py1br`, and `py1e` \ From fac8fd81e0528aee11b9b69295e53b45af93de1b Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 4 Oct 2023 00:06:54 -0700 Subject: [PATCH 18/31] fixed all typing and pyright stuff --- Makefile | 2 +- simulation/core/create_index.py | 72 +- simulation/core/last_used_ordered_set.py | 1 + simulation/core/main.py | 106 +-- simulation/core/node_tracker.py | 156 +++-- simulation/core/shard_downloads.py | 57 +- simulation/core/shuffle_quality.py | 120 ++-- simulation/core/sim_time.py | 37 +- simulation/core/simulation_dataset.py | 133 ++-- simulation/core/simulation_spanner.py | 1 + simulation/core/simulation_world.py | 26 +- simulation/core/utils.py | 74 ++- simulation/core/yaml_processing.py | 85 +-- simulation/interfaces/interface_utils.py | 68 +- .../{simulation_script.py => sim_script.py} | 79 ++- simulation/interfaces/sim_ui.py | 397 +++++++++++ simulation/interfaces/simcli.py | 79 ++- simulation/interfaces/simulation_ui.py | 339 ---------- simulation/interfaces/widgets.py | 619 ++++++++++-------- simulation/testing/simulation_testing.py | 169 ----- simulation/testing/wandb_testing.py | 207 ++++++ streaming/base/shuffle/__init__.py | 2 - streaming/base/shuffle/py1br.py | 2 +- streaming/base/shuffle/py1e.py | 2 +- 24 files changed, 1526 insertions(+), 1307 deletions(-) rename simulation/interfaces/{simulation_script.py => sim_script.py} (62%) create mode 100644 simulation/interfaces/sim_ui.py delete mode 100644 simulation/interfaces/simulation_ui.py delete mode 100644 simulation/testing/simulation_testing.py create mode 100644 simulation/testing/wandb_testing.py diff --git a/Makefile b/Makefile index b3a294311..1015ec416 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,6 @@ web: uvicorn scripts.partition.web:app --port 1337 --reload simulator: - streamlit run simulation/interfaces/simulation_ui.py + streamlit run simulation/interfaces/sim_ui.py .PHONY: test lint style diff --git a/simulation/core/create_index.py b/simulation/core/create_index.py index b22906252..1cbdb5c50 100644 --- a/simulation/core/create_index.py +++ b/simulation/core/create_index.py @@ -9,20 +9,21 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) import json -from streaming.base import Stream -from typing import Optional +import os import random import string +from typing import Optional + def get_random_foldername(): """Generate random folder name to store the index file in.""" - - return ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(16)) + return ''.join( + random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) + for _ in range(16)) -def create_stream_index(shards: int, - samples_per_shard: int, - avg_raw_shard_size: int, - avg_zip_shard_size: Optional[int]) -> Stream: + +def create_stream_index(shards: int, samples_per_shard: int, avg_raw_shard_size: int, + avg_zip_shard_size: Optional[int]) -> str: """Create dataset index file from input parameters. Args: @@ -30,54 +31,53 @@ def create_stream_index(shards: int, samples_per_shard (int): Number of samples per shard. avg_raw_shard_size (int): Average raw shard size. avg_zip_shard_size (int): Average compressed shard size. + Returns: local path to created index file for stream. """ - index_data = { - "version": 2, - } + index_data = {'version': 2, 'shards': []} shards_list = [] for shard_id in range(shards): shard_data = { - "column_encodings": [], - "column_names": [], - "column_sizes": [], - "format": "mds", - "raw_data": { - "basename": "shard."+str(shard_id)+".mds", - "bytes": avg_raw_shard_size, - "hashes": {} + 'column_encodings': [], + 'column_names': [], + 'column_sizes': [], + 'format': 'mds', + 'raw_data': { + 'basename': 'shard.' + str(shard_id) + '.mds', + 'bytes': avg_raw_shard_size, + 'hashes': {} }, - "hashes": [], - "samples": samples_per_shard, - "size_limit": avg_raw_shard_size, - "version": 2, - "zip_data": None, - "compression": None + 'hashes': [], + 'samples': samples_per_shard, + 'size_limit': avg_raw_shard_size, + 'version': 2, + 'zip_data': None, + 'compression': None } if avg_zip_shard_size is not None: - shard_data["zip_data"] = { - "basename": "shard."+str(shard_id)+".mds.zstd", - "bytes": avg_zip_shard_size, - "hashes": {} + shard_data['zip_data'] = { + 'basename': 'shard.' + str(shard_id) + '.mds.zstd', + 'bytes': avg_zip_shard_size, + 'hashes': {} } - shard_data["compression"] = "zstd:16" + shard_data['compression'] = 'zstd:16' shards_list.append(shard_data) - index_data["shards"] = shards_list + index_data['shards'] = shards_list # Try making the directory for the stream's index.json file - foldername = get_random_foldername() + "_indexcreated" + foldername = get_random_foldername() + '_indexcreated' try: os.mkdir(foldername) except FileExistsError: - print("Folder already exists, trying again...") + print('Folder already exists, trying again...') foldername = get_random_foldername() os.mkdir(foldername) - with open(foldername+'/index.json', 'w') as f: + with open(foldername + '/index.json', 'w') as f: json.dump(index_data, f) f.close() - - return os.path.join(foldername, 'index.json') \ No newline at end of file + + return os.path.join(foldername, 'index.json') diff --git a/simulation/core/last_used_ordered_set.py b/simulation/core/last_used_ordered_set.py index ba3a7d1cf..39acd9d18 100644 --- a/simulation/core/last_used_ordered_set.py +++ b/simulation/core/last_used_ordered_set.py @@ -6,6 +6,7 @@ from collections import OrderedDict from typing import Any + class LastUsedOrderedSet(OrderedDict): """An ordered dict that can be used as an LRU cache. diff --git a/simulation/core/main.py b/simulation/core/main.py index 0a52e578f..ccd48275d 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -8,26 +8,26 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import time +from typing import Generator, Union import numpy as np -from numpy.typing import NDArray -from core.shard_downloads import simulate_shard_downloads, run_cache_limit +from core.node_tracker import NodeTracker +from core.shard_downloads import run_cache_limit, simulate_shard_downloads from core.sim_time import Time -from typing import Union -import time - from core.simulation_dataset import SimulationDataset -from core.node_tracker import NodeTracker -from core.utils import get_batches_epochs, bytes_to_time, time_to_bytes - -def simulate(dataset: SimulationDataset, - time_per_sample: float, - node_network_bandwidth: Union[float,str], - generator: bool = False, - max_duration: Time = None - ) -> Union[tuple[int, float, int], - tuple[float, int], - tuple[NDArray, NDArray, float, int]]: +from core.utils import bytes_to_time, get_batches_epochs, time_to_bytes +from numpy.typing import NDArray + + +def simulate( + dataset: SimulationDataset, + time_per_sample: float, + node_network_bandwidth: int, + max_duration: Time, + generator: bool = False +) -> Generator[Union[tuple[int, float, int], tuple[float, int], tuple[NDArray, NDArray, float, + int]], None, None]: """Simulates step time and downloads using streaming for the specified input parameters. At each training step, the simulation does the following: @@ -37,7 +37,7 @@ def simulate(dataset: SimulationDataset, * tracks the time the the step took as well as downloaded bytes. Key Notes and Assumptions: - * assume that batch time is solely made up of two things: batch shard download wait time + * assume that batch time is solely made up of two things: batch shard download wait time and batch processing time * loop through workers in a node round-robin style for batches and for downloads * assume each node has a separate, uniform network bandwidth @@ -52,25 +52,25 @@ def simulate(dataset: SimulationDataset, Args: dataset (SimulationDataset): SimulationDataset object created based on input params/yaml time_per_sample (float): time to process one sample on one device (seconds) - node_network_bandwidth (Union[float, str]): network bandwidth per node (bytes/s) + node_network_bandwidth (int): network bandwidth per node (bytes/s) + max_duration (Time): max duration of simulation. Defaults to ``None``. generator (bool): True if we yield throughput and shard_download one step at a time. - max_duration (Time, optional): max duration of simulation. Defaults to ``None``. Returns: - Union[tuple[int, float, int], - tuple[NDArray, NDArray, float, int], - tuple[float, int]]: either a tuple of step number, step time, and downloaded bytes, - a tuple of startup time and min needed cache limit, (both when generator=True), or a - tuple of all step times, downloaded bytes, startup_time, and min needed cache limit. + Generator[Union[tuple[int, float, int], + tuple[NDArray, NDArray, float, int], + tuple[float, int]], None, None]: either a tuple of step number, step time, and + downloaded bytes, a tuple of startup time and min needed cache limit, + (both when generator=True), or a tuple of all step times, downloaded bytes, + startup_time, and min needed cache limit. """ - # tracking startup time, which includes SimulationDataset instantiation time. start_time = time.time() startup_time = dataset.get_instantiation_time() # Get batches, epochs, total batches from dataset and provided time info. batches_per_epoch, epochs, total_batches = get_batches_epochs(dataset, max_duration) - + # Retrieve streaming and dataset information from SimulationDataset. physical_nodes = dataset.get_nodes() devices = dataset.get_devices() @@ -88,9 +88,10 @@ def simulate(dataset: SimulationDataset, # cache usage, shard usage ranges, etc. for each node. nodes = [] for _ in range(physical_nodes): - nodes.append(NodeTracker(workers, devices, predownload, device_batch_size, - total_shards, cache_limit)) - + nodes.append( + NodeTracker(workers, devices, predownload, device_batch_size, total_shards, + cache_limit)) + # Time for the global batch is just device batch size * time per sample. # We assume all devices process their microbatch perfectly in parallel. avg_batch_process_time = device_batch_size * time_per_sample @@ -104,7 +105,7 @@ def simulate(dataset: SimulationDataset, step_downloads = [] for epoch in range(epochs): - + # Get the samples, divided up per node, for this epoch. samples_per_node = dataset.get_samples_per_node(epoch, 0) @@ -115,11 +116,11 @@ def simulate(dataset: SimulationDataset, # Track which sample we are currently on, as a worker id. We round-robin over workers. worker_sample_index = 0 - + # if first epoch, add time so far to startup time if epoch == 0: startup_time += time.time() - start_time - + # Iterate over batches for batch in range(batches_per_epoch): @@ -133,7 +134,7 @@ def simulate(dataset: SimulationDataset, if (batch + 1) % notification_batches == 0: print('Epoch: ' + str(epoch + 1) + ' | Batch ' + str(batch + 1) + '/' + str(batches_per_epoch)) - + # We round-robin over workers per device. The current batch's worker is the same # across every device. curr_worker = batch % workers @@ -145,9 +146,8 @@ def simulate(dataset: SimulationDataset, # Get current samples and download samples for each node, for this batch. for node_id, node in enumerate(nodes): - shards_needed, shards_present = node.get_current_batch_shards(curr_worker, - worker_sample_index, - sample_to_shard) + shards_needed, shards_present = node.get_current_batch_shards( + curr_worker, worker_sample_index, sample_to_shard) # Mark all shards present as accessed most recently in this node. node.set_shards_used(shards_present, step_num) # Push the predownload for the current batch workers ahead by device_batch_size. @@ -159,27 +159,27 @@ def simulate(dataset: SimulationDataset, # other than the current batch's shards while looking for current batch's shards. while len(shards_needed) > 0: download_outcome, download_size = \ - simulate_shard_downloads(node, - raw_shard_sizes, - zip_shard_sizes, + simulate_shard_downloads(node, + raw_shard_sizes, + zip_shard_sizes, current_batch_downloads=True, step_num=step_num, cache_limit=cache_limit, shards_needed=shards_needed) - if download_outcome == "downloaded": + if download_outcome == 'downloaded': node_downloaded_bytes += download_size downloaded_bytes += download_size - elif download_outcome == "present": + elif download_outcome == 'present': # If the shard was already present in the node, continue downloading. pass else: # If no shard downloads are left in the node, stop downloading. break - + # Calculate how much time this node spent downloading shards node_batch_download_times[node_id] = bytes_to_time(node_downloaded_bytes, node_network_bandwidth) - + # The node that took the longest to download shards is the bottleneck. All other nodes # use the extra time to continue downloading. slowest_download_time = np.max(node_batch_download_times) @@ -193,7 +193,7 @@ def simulate(dataset: SimulationDataset, # The download time each node has is the avg_batch_process_time plus the extra time # the node has from finishing earlier than the slowest node. download_time_left = avg_batch_process_time + (slowest_download_time - - node_batch_download_times[node_id]) + node_batch_download_times[node_id]) # Get number of bytes we can download in download_time_left. # We also include any partially downloaded bytes from previous steps. download_bytes_left = time_to_bytes(download_time_left, node_network_bandwidth) + \ @@ -201,36 +201,36 @@ def simulate(dataset: SimulationDataset, while True: download_outcome, download_size = \ - simulate_shard_downloads(node, - raw_shard_sizes, - zip_shard_sizes, + simulate_shard_downloads(node, + raw_shard_sizes, + zip_shard_sizes, current_batch_downloads=False, step_num=step_num, cache_limit=cache_limit, download_bytes_left=download_bytes_left) - if download_outcome == "downloaded": + if download_outcome == 'downloaded': downloaded_bytes += download_size download_bytes_left -= download_size - elif download_outcome == "present": + elif download_outcome == 'present': pass else: # If no shard downloads are left in the node, or we could only partially # download a shard, stop downloading. break - + # Yield or store step number, time and download for this step if generator: yield step_num, slowest_download_time + avg_batch_process_time, downloaded_bytes else: step_times.append(slowest_download_time + avg_batch_process_time) step_downloads.append(downloaded_bytes) - + # Increment worker_sample_index by device_batch_size if we are at the last worker. # As we round-robin over workers, the sample index per worker is increased as we loop # through all workers. if curr_worker == workers - 1: worker_sample_index += device_batch_size - + # Simulation is finished. Calculate needed cache limit. min_cache_limit = run_cache_limit(nodes, raw_shard_sizes) @@ -240,4 +240,4 @@ def simulate(dataset: SimulationDataset, step_downloads = np.array(step_downloads) yield step_times, step_downloads, startup_time, min_cache_limit else: - yield startup_time, min_cache_limit \ No newline at end of file + yield startup_time, min_cache_limit diff --git a/simulation/core/node_tracker.py b/simulation/core/node_tracker.py index e74cc3983..73c6c03b7 100644 --- a/simulation/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -8,28 +8,37 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from typing import Optional + +import numpy as np from core.last_used_ordered_set import LastUsedOrderedSet from core.utils import remove_padded_samples from numpy.typing import NDArray from sortedcollections import OrderedSet + from streaming.base.spanner import Spanner -from typing import Optional -import numpy as np -class NodeTracker(): - def __init__(self, workers: int, devices: int, predownload: int, device_batch_size: int, - total_shards: int, cache_limit: Optional[int] = None): - """Tracker for node information during simulation. +class NodeTracker(): + """Tracker for node information during simulation. + + Args: + workers (int): The number of workers. + devices (int): The number of devices. + predownload (int): The number of samples to predownload. + device_batch_size (int): The device batch size. + total_shards (int): Total number of shards in the dataset. + cache_limit (Optional[int]): The cache limit for the node. Defaults to None. + """ + + def __init__(self, + workers: int, + devices: int, + predownload: int, + device_batch_size: int, + total_shards: int, + cache_limit: Optional[int] = None): - Args: - workers (int): The number of workers. - devices (int): The number of devices. - predownload (int): The number of samples to predownload. - device_batch_size (int): The device batch size. - total_shards (int): Total number of shards in the dataset. - cache_limit (Optional[int]): The cache limit for the node. Defaults to None. - """ self.shards = LastUsedOrderedSet() self.all_shards = set() self.cache_usage = 0 @@ -47,25 +56,25 @@ def __init__(self, workers: int, devices: int, predownload: int, device_batch_si # Use the set_epoch_samples method every epoch to set the node's samples. self.samples = None - + def initialize_worker_downloads(self, sample_to_shard: Spanner): """Initialize worker downloads, making shards in the predownload sample range available. - + Args: sample_to_shard (Spanner): The mapping from samples to shards. """ # For downloads, we round-robin over devices first, then workers. - if self.samples is None: - raise ValueError("Must set samples before initializing worker downloads.") - else: + if self.samples is not None: for worker in range(self.workers): for device in range(self.devices): - download_samples = remove_padded_samples(self.samples[device, worker, - :self.predownload]) + download_samples = remove_padded_samples( + self.samples[device, worker, :self.predownload]) # Get the shards these samples correspond to, maintaining access order. - download_shards = OrderedSet([sample_to_shard[sample] - for sample in download_samples]) + download_shards = OrderedSet( + [sample_to_shard[sample] for sample in download_samples]) self.worker_downloads.append(download_shards) + else: + raise AttributeError('Must set node samples before accessing them.') def set_shards_used(self, shards: set, step_num: int): """Mark a set of shards as recently used. @@ -78,10 +87,10 @@ def set_shards_used(self, shards: set, step_num: int): self.shards.setuse(shard) # For any shard access, we are accessing the shard so we need the shard until # at least the next step begins. Adding 0.5 ensures that we evict shards - # after they are used for the last time, but before they are replaced by + # after they are used for the last time, but before they are replaced by # new downloads in the next step. self.shard_access_ends[shard] = step_num + 0.5 - + def add_shard(self, shard: int, used: bool = True): """Add a shard to the node. @@ -95,7 +104,7 @@ def add_shard(self, shard: int, used: bool = True): def get_all_shards(self): """Get all the shards in the node.""" return self.all_shards - + def evict_shard(self) -> int: """Evict a shard. @@ -104,18 +113,19 @@ def evict_shard(self) -> int: """ evicted_shard = self.shards.popLRU() return evicted_shard - - def evict_until_satisfied(self, incoming_shard_size: int, raw_shard_sizes: NDArray): + + def evict_until_satisfied(self, incoming_shard_size: int, raw_shard_sizes: NDArray[np.int64]): """Evict shards until the node has enough space to download the incoming shard. Args: incoming_shard_size (int): The size of the incoming shard. - raw_shard_sizes (NDArray): The raw shard sizes. + raw_shard_sizes (NDArray[np.int64]): The raw shard sizes. """ # We evict shards until the incoming shard fits into the node's cache. - while self.cache_usage + incoming_shard_size > self.cache_limit: - evicted_shard = self.evict_shard() - self.cache_usage -= raw_shard_sizes[evicted_shard] + if self.cache_limit is not None: + while self.cache_usage + incoming_shard_size > self.cache_limit: + evicted_shard = self.evict_shard() + self.cache_usage -= int(raw_shard_sizes[evicted_shard]) def increment_worker_download_index(self): """Increment the worker download index.""" @@ -123,15 +133,16 @@ def increment_worker_download_index(self): (self.workers * self.devices) def get_worker_download(self, - worker: Optional[int] = None, - device: Optional[int] = None, - index: Optional[int] = None) -> OrderedSet: + worker: Optional[int] = None, + device: Optional[int] = None, + index: Optional[int] = None) -> OrderedSet: """Get the shard downloads for a worker on a specific device. Args: worker (Optional[int]): The worker index. device (Optional[int]): The device index the worker is on. index (Optional[int]): The index of the worker download for direct access. + Returns: OrderedSet: The shard downloads, in order, for this worker. """ @@ -142,10 +153,9 @@ def get_worker_download(self, # Access worker_downloads through worker and device indices. return self.worker_downloads[worker * self.devices + device] else: - raise ValueError("Must specify either index, or worker and device.") - - def get_current_batch_shards(self, worker: int, - worker_sample_index: int, + raise ValueError('Must specify either index, or worker and device.') + + def get_current_batch_shards(self, worker: int, worker_sample_index: int, sample_to_shard: Spanner) -> tuple[set, set]: """Get this node's shards for the current batch. @@ -153,17 +163,21 @@ def get_current_batch_shards(self, worker: int, worker (int): The worker. worker_sample_index (int): The worker sample index. sample_to_shard (Spanner): The mapping from samples to shards. + Returns: tuple[set, set]: shard ids needed by node, shard ids present in node. """ - batch_samples = remove_padded_samples(self.samples[:, worker, - worker_sample_index: - worker_sample_index + self.device_batch_size].flatten()) - batch_shards = set([sample_to_shard[sample] for sample in batch_samples]) - shards_needed = batch_shards.difference(self.shards.keys()) - shards_present = batch_shards.difference(shards_needed) - return shards_needed, shards_present - + if self.samples is not None: + batch_samples = remove_padded_samples( + self.samples[:, worker, worker_sample_index:worker_sample_index + + self.device_batch_size].flatten()) + batch_shards = {sample_to_shard[sample] for sample in batch_samples} + shards_needed = batch_shards.difference(self.shards.keys()) + shards_present = batch_shards.difference(shards_needed) + return shards_needed, shards_present + else: + raise AttributeError('Must set node samples before accessing them.') + def get_next_worker_with_downloads(self) -> Optional[OrderedSet]: """Get the next worker with samples to download. @@ -181,12 +195,11 @@ def get_next_worker_with_downloads(self) -> Optional[OrderedSet]: # No workers in the node have samples to download. if empty_download_counter >= self.total_workers: return None - + return worker_download - - def update_worker_predownloads(self, worker: int, - worker_sample_index: int, - sample_to_shard: Spanner): + + def update_worker_predownloads(self, worker: int, worker_sample_index: int, + sample_to_shard: Spanner): """Update the worker predownload samples for a worker and device. Args: @@ -194,22 +207,23 @@ def update_worker_predownloads(self, worker: int, worker_sample_index (int): The worker sample index. sample_to_shard (Spanner): The mapping from samples to shards. """ - for device in range(self.devices): - # Retrieve new samples that are now within predownload range of the worker. - new_download_samples = remove_padded_samples(self.samples[device, worker, - worker_sample_index + self.predownload: - worker_sample_index + self.device_batch_size + - self.predownload]) - - # Want to maintain the shard access order. - new_download_shards = OrderedSet([sample_to_shard[sample] - for sample in new_download_samples]) - - worker_downloads = self.get_worker_download(worker=worker, device=device) - - # Add in new shards to the worker's shard downloads only if node does not yet have it. - for shard in new_download_shards: - if shard not in self.shards: - worker_downloads.add(shard) - - \ No newline at end of file + if self.samples is not None: + for device in range(self.devices): + # Retrieve new samples that are now within predownload range of the worker. + new_download_samples = remove_padded_samples( + self.samples[device, worker, + worker_sample_index + self.predownload:worker_sample_index + + self.device_batch_size + self.predownload]) + + # Want to maintain the shard access order. + new_download_shards = OrderedSet( + [sample_to_shard[sample] for sample in new_download_samples]) + + worker_downloads = self.get_worker_download(worker=worker, device=device) + + # Add in new shards to the worker's shard downloads only if node does not yet have it. + for shard in new_download_shards: + if shard not in self.shards: + worker_downloads.add(shard) + else: + raise AttributeError('Must set node samples before accessing them.') diff --git a/simulation/core/shard_downloads.py b/simulation/core/shard_downloads.py index b96b71811..12d00b2c1 100644 --- a/simulation/core/shard_downloads.py +++ b/simulation/core/shard_downloads.py @@ -8,24 +8,27 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from typing import Optional + +import numpy as np from core.node_tracker import NodeTracker from numpy.typing import NDArray -from typing import Optional + def simulate_shard_downloads(node: NodeTracker, - raw_shard_sizes: NDArray, - zip_shard_sizes: NDArray, + raw_shard_sizes: NDArray[np.int64], + zip_shard_sizes: NDArray[np.int64], current_batch_downloads: bool, step_num: int, cache_limit: Optional[int] = None, shards_needed: Optional[set] = None, - download_bytes_left: Optional[int] = None) -> tuple[bool, int]: + download_bytes_left: Optional[int] = None) -> tuple[str, int]: """Simulate downloading a shard for a node. Args: node (NodeTracker): The node to simulate downloading a shard for. - raw_shard_sizes (NDArray): The raw sizes of all shards. - zip_shard_sizes (NDArray): The zip sizes of all shards. + raw_shard_sizes (NDArray[np.int64]): The raw sizes of all shards. + zip_shard_sizes (NDArray[np.int64]): The zip sizes of all shards. current_batch_downloads (bool): Whether we are downloading shards for the current batch. step_num (int): The current step number. cache_limit (Optional[int]): The cache limit for the node. Defaults to ``None``. @@ -37,18 +40,17 @@ def simulate_shard_downloads(node: NodeTracker, Returns: tuple[bool, int]: A tuple of the shard download status and the download size. """ - worker_download = node.get_next_worker_with_downloads() if worker_download is None: # No workers have shards to download. - return ("empty", 0) - + return ('empty', 0) + # Proceed with downloading the shard for this worker. - download_shard = worker_download[0] + download_shard = int(worker_download[0]) # Get the raw and zip sizes, in bytes, of the shard. - shard_raw_size = raw_shard_sizes[download_shard] - shard_zip_size = zip_shard_sizes[download_shard] + shard_raw_size = int(raw_shard_sizes[download_shard]) + shard_zip_size = int(zip_shard_sizes[download_shard]) # If shard is compressed, we download the zipped size. Otherwise, download raw size. download_size = shard_zip_size or shard_raw_size @@ -59,8 +61,8 @@ def simulate_shard_downloads(node: NodeTracker, if download_bytes_left is not None: bytes_sufficient = (download_size <= download_bytes_left) else: - raise ValueError("Must specify download_bytes_left if not downloading for \ - current batch.") + raise ValueError('Must specify download_bytes_left if not downloading for \ + current batch.') if download_shard not in node.shards and bytes_sufficient: # Shard is not present in node, so we download it. @@ -68,7 +70,7 @@ def simulate_shard_downloads(node: NodeTracker, if cache_limit and node.cache_usage + shard_raw_size > cache_limit: # Evict shards until we have space for this shard. node.evict_until_satisfied(shard_raw_size, raw_shard_sizes) - + # Shards are assumed to be decompressed on download, so cache_usage increases by raw size. node.cache_usage += shard_raw_size @@ -80,45 +82,46 @@ def simulate_shard_downloads(node: NodeTracker, node.add_shard(download_shard) shards_needed.discard(download_shard) else: - raise ValueError("Must specify shards_needed if downloading for current batch.") + raise ValueError('Must specify shards_needed if downloading for current batch.') else: node.add_shard(download_shard) - if node.shard_access_starts[download_shard] == -1: # Shard has never been accessed before. Set its access start. node.shard_access_starts[download_shard] = step_num # For any shard access, we are accessing the shard so we need the shard until # at least the next step begins. Adding 0.5 ensures that we evict shards - # after they are used for the last time, but before they are replaced by + # after they are used for the last time, but before they are replaced by # new downloads in the next step. node.shard_access_ends[download_shard] = step_num + 0.5 - + # Advance the worker download index. node.increment_worker_download_index() # We have now downloaded this shard. Remove from worker download queue. worker_download.pop() - return ("downloaded", download_size) - elif not(current_batch_downloads) and download_shard not in node.shards: + return ('downloaded', download_size) + elif not (current_batch_downloads) and download_shard not in node.shards \ + and download_bytes_left is not None: # This is the case when we are not downloading for the current batch, and need to download # a shard but do not have the download bytes to fully download the shard this step. # We do not advance the worker download index since we still are downloading this shard. node.partial_shard_bytes = download_bytes_left # We only account for downloaded bytes when we fully download a shard. - return ("partial", 0) + return ('partial', 0) else: # The shard is already present in the node. Advance the worker download index. node.increment_worker_download_index() # Node already has this shard. Remove from worker download queue. worker_download.pop() - return ("present", 0) - -def run_cache_limit(nodes: list[NodeTracker], raw_shard_sizes: NDArray) -> int: + return ('present', 0) + + +def run_cache_limit(nodes: list[NodeTracker], raw_shard_sizes: NDArray[np.int64]) -> int: """Find the minimum needed cache limit across all nodes for this run. Args: nodes (list[NodeTracker]): The nodes, which contain shard use information. - raw_shard_sizes (NDArray): The raw sizes of all shards. + raw_shard_sizes (NDArray[np.int64]): The raw sizes of all shards. Returns: int: The minimum needed cache limit, in bytes, for the run. @@ -151,4 +154,4 @@ def run_cache_limit(nodes: list[NodeTracker], raw_shard_sizes: NDArray) -> int: # Shard access has ended. Decrement cache usage. curr_cache_usage -= raw_shard_sizes[event[1]] - return needed_cache_usage \ No newline at end of file + return needed_cache_usage diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index 7dcdbf95b..4cf743883 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -9,17 +9,19 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) import numpy as np +from core.utils import remove_padded_samples +from numpy.typing import NDArray + from streaming.base.partition.orig import get_partitions_orig from streaming.base.shuffle import get_shuffle -from numpy.typing import NDArray -from core.utils import remove_padded_samples + def get_entropy(ordering: NDArray) -> float: """Calculate the entropy of an ordering, which is initially assumed to be in ascending order. Args: ordering (NDArray): The ordering to calculate the entropy of. - + Returns: float: The entropy of the ordering. """ @@ -32,24 +34,21 @@ def get_entropy(ordering: NDArray) -> float: # count frequencies of differences diff_freqs = np.bincount(diffs) - + # remove zero frequency elements d diff_freqs = diff_freqs[diff_freqs != 0] # convert frequencies to probabilities - diff_probs = diff_freqs / (ordering.shape[0]-1) + diff_probs = diff_freqs / (ordering.shape[0] - 1) # calculate entropy - diff_entropy = -np.sum(diff_probs*np.log2(diff_probs)) + diff_entropy = -np.sum(diff_probs * np.log2(diff_probs)) - return diff_entropy + return float(diff_entropy) -def get_partition_shard_info(epoch_size: int, - canonical_nodes: int, - physical_nodes: int, - devices: int, - workers: int, - device_batch_size: int, + +def get_partition_shard_info(epoch_size: int, canonical_nodes: int, physical_nodes: int, + devices: int, workers: int, device_batch_size: int, samples_per_shard: int) -> tuple[NDArray, NDArray, NDArray]: """Partition up to 100 million samples and get associated shard information. @@ -61,43 +60,39 @@ def get_partition_shard_info(epoch_size: int, workers (int): The number of workers. device_batch_size (int): The batch size per device. samples_per_shard (int): Average number of samples per shard. - + Returns: tuple[NDArray, NDArray, NDArray]: The partition, in order, the sizes of each shard, and the mapping of sample id to shard id. """ - num_samples = epoch_size if num_samples > 100000000: - print("Epoch size is >100 million. Using 100 million samples to analyze shuffle quality.") + print('Epoch size is >100 million. Using 100 million samples to analyze shuffle quality.') num_samples = 100000000 - partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, - devices, workers, device_batch_size) + partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, devices, workers, + device_batch_size) partition = partition.transpose(3, 2, 0, 1, 4).flatten() partition = remove_padded_samples(partition) # Construct shard sizes array. num_shards = num_samples // samples_per_shard - shard_sizes = np.array([samples_per_shard]*num_shards) + shard_sizes = np.array([samples_per_shard] * num_shards) if num_samples % samples_per_shard != 0: num_shards += 1 shard_sizes = np.append(shard_sizes, num_samples % samples_per_shard) # Construct sample id -> shard id mapping. - shard_per_sample = np.repeat(np.arange(num_shards-1), samples_per_shard) + shard_per_sample = np.repeat(np.arange(num_shards - 1), samples_per_shard) remaining_samples = num_samples - len(shard_per_sample) - shard_per_sample = np.append(shard_per_sample, np.full(remaining_samples, num_shards-1)) + shard_per_sample = np.append(shard_per_sample, np.full(remaining_samples, num_shards - 1)) return partition, shard_sizes, shard_per_sample -def get_entropy_shuffle_quality(shuffle_algo: str, - partition: NDArray, - shard_sizes: NDArray, - shard_per_sample: NDArray, - canonical_nodes: int, - seed: int, - shuffle_block_size: int) -> float: + +def get_entropy_shuffle_quality(shuffle_algo: str, partition: NDArray, shard_sizes: NDArray, + shard_per_sample: NDArray, canonical_nodes: int, seed: int, + shuffle_block_size: int) -> float: """Get the entropy of a shuffle, assuming samples and shards were initially in ascending order. Args: @@ -108,30 +103,24 @@ def get_entropy_shuffle_quality(shuffle_algo: str, canonical_nodes (int): The number of canonical nodes. seed (int): The seed to use for the shuffle. shuffle_block_size (int): The shuffle block size. - + Returns: float: The entropy of the shuffle, combining entropy from sample and shard orderings. """ - if shuffle_algo != 'none': # Assume we are shuffling only for epoch 0. - shuffle_ordering = get_shuffle(shuffle_algo, shard_sizes, canonical_nodes, - seed, 0, shuffle_block_size) + shuffle_ordering = get_shuffle(shuffle_algo, shard_sizes, canonical_nodes, seed, 0, + shuffle_block_size) partition = shuffle_ordering[partition] sample_entropy = get_entropy(partition) shard_entropy = get_entropy(shard_per_sample[partition]) return sample_entropy + shard_entropy -def analyze_all_shuffle_quality(algos: list[str], - canonical_nodes: int, - physical_nodes: int, - devices: int, - workers: int, - device_batch_size: int, - shuffle_block_size: int, - samples_per_shard: int, - epoch_size: int, - seed: int) -> list[tuple[str, float]]: + +def analyze_all_shuffle_quality(algos: list[str], canonical_nodes: int, physical_nodes: int, + devices: int, workers: int, device_batch_size: int, + shuffle_block_size: int, samples_per_shard: int, epoch_size: int, + seed: int) -> list[tuple[str, float]]: """Analyze the quality of this shuffle across algorithms. Args: @@ -145,37 +134,29 @@ def analyze_all_shuffle_quality(algos: list[str], samples_per_shard (int): Average number of samples per shard. epoch_size (int): The number of samples in an epoch. seed (int): The seed to use for the shuffle. + Returns: list[tuple[str, float]]: Shuffle algorithms and shuffle qualities. """ - - print("Analyzing shuffle quality...") + print('Analyzing shuffle quality...') shuffle_qualities = [] # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. - partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, - canonical_nodes, - physical_nodes, - devices, workers, - device_batch_size, - samples_per_shard) + partition, shard_sizes, shard_per_sample = get_partition_shard_info( + epoch_size, canonical_nodes, physical_nodes, devices, workers, device_batch_size, + samples_per_shard) for algo in algos: - shuffle_qualities.append(get_entropy_shuffle_quality(algo, partition, shard_sizes, - shard_per_sample, canonical_nodes, - seed, shuffle_block_size)) + shuffle_qualities.append( + get_entropy_shuffle_quality(algo, partition, shard_sizes, shard_per_sample, + canonical_nodes, seed, shuffle_block_size)) return shuffle_qualities -def analyze_shuffle_quality(algo: str, - canonical_nodes: int, - physical_nodes: int, - devices: int, - workers: int, - device_batch_size: int, - shuffle_block_size: int, - samples_per_shard: int, - epoch_size: int, + +def analyze_shuffle_quality(algo: str, canonical_nodes: int, physical_nodes: int, devices: int, + workers: int, device_batch_size: int, shuffle_block_size: int, + samples_per_shard: int, epoch_size: int, seed: int) -> tuple[str, float]: """Analyze the quality of a shuffle for one algorithm. @@ -190,20 +171,17 @@ def analyze_shuffle_quality(algo: str, samples_per_shard (int): Average number of samples per shard. epoch_size (int): The number of samples in an epoch. seed (int): The seed to use for the shuffle. + Returns: tuple[str, float]: Shuffle algorithm and shuffle quality. """ - - print(f"Analyzing shuffle quality for {algo}...") + print(f'Analyzing shuffle quality for {algo}...') # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. - partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, - canonical_nodes, - physical_nodes, - devices, workers, - device_batch_size, - samples_per_shard) - + partition, shard_sizes, shard_per_sample = get_partition_shard_info( + epoch_size, canonical_nodes, physical_nodes, devices, workers, device_batch_size, + samples_per_shard) + shuffle_quality = get_entropy_shuffle_quality(algo, partition, shard_sizes, shard_per_sample, canonical_nodes, seed, shuffle_block_size) diff --git a/simulation/core/sim_time.py b/simulation/core/sim_time.py index d36943684..5a7f2c042 100644 --- a/simulation/core/sim_time.py +++ b/simulation/core/sim_time.py @@ -1,13 +1,16 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Time classes ported from MosaicML composer. Avoids dependency on composer and its many reqs.""" +"""Time classes ported from MosaicML composer. + +Avoids dependency on composer and its many reqs. +""" from __future__ import annotations -import datetime + import re from enum import Enum -from typing import Any, Dict, Generic, Optional, TypeVar, Union, cast +from typing import Generic, TypeVar, Union, cast class TimeUnit(Enum): @@ -28,11 +31,13 @@ class TimeUnit(Enum): # regex for parsing time string, matches timeunit and chars prior to unit as value -_TIME_STR_REGEX = re.compile(r'^(.+)(' + r'|'.join([fr'{time_unit.value}' for time_unit in TimeUnit]) + r')$', +_TIME_STR_REGEX = re.compile(r'^(.+)(' + + r'|'.join([fr'{time_unit.value}' for time_unit in TimeUnit]) + r')$', flags=re.IGNORECASE) TValue = TypeVar('TValue', int, float) + class Time(Generic[TValue]): """Time represents static durations of training time in terms of a :class:`TimeUnit` enum. @@ -101,7 +106,9 @@ def __init__( value = cast(TValue, float(value)) else: if not isinstance(value, int): - raise TypeError(f'value {value} is of type {type(value)}. Units {unit} require integer values.') + raise TypeError( + f'value {value} is of type {type(value)}. Units {unit} require integer values.' + ) self._value, self._unit = value, TimeUnit(unit) @classmethod @@ -218,17 +225,16 @@ def _parse(self, other: object) -> Time: raise TypeError(f'Cannot convert type {other} to {self.__class__.__name__}') def _cmp(self, other: Union[int, float, Time, str]) -> int: - # When doing comparisions, and other is an integer (or float), we can safely infer + # When doing comparisons, and other is an integer (or float), we can safely infer # the unit from self.unit # E.g. calls like this should be allowed: if batch < 42: do_something() # This eliminates the need to call .value everywhere - if not isinstance(other, (int, float, Time, str)): - return NotImplemented if isinstance(other, (int, float)): other = type(self)(other, self.unit) other = self._parse(other) if self.unit != other.unit: - raise RuntimeError(f'Cannot compare {self} to {other} since they have different units.') + raise RuntimeError( + f'Cannot compare {self} to {other} since they have different units.') if self.value < other.value: return -1 if self.value == other.value: @@ -266,7 +272,8 @@ def __radd__(self, other: Union[int, float, Time, str]) -> Time[TValue]: def __sub__(self, other: Union[int, float, Time, str]) -> Time[TValue]: other = self._parse(other) if self.unit != other.unit: - raise RuntimeError(f'Cannot subtract {other} from {self} since they have different units.') + raise RuntimeError( + f'Cannot subtract {other} from {self} since they have different units.') return Time(self.value - other.value, self.unit) def __rsub__(self, other: Union[int, float, Time, str]) -> Time[TValue]: @@ -336,10 +343,12 @@ def from_timestring(cls, timestring: str) -> Time: value = float(value) # always parsing first as float b/c it could be scientific notation if unit != TimeUnit.DURATION: if int(value) != value: - raise TypeError(f'value {value} is not an integer. Units {unit} require integer values.') + raise TypeError( + f'value {value} is not an integer. Units {unit} require integer values.') value = int(value) return cls(value, unit) - + + def ensure_time(maybe_time: Union[Time, str, int], int_unit: Union[TimeUnit, str]) -> Time: """Ensure ``maybe_time`` is an instance of :class:`.Time`. @@ -354,6 +363,4 @@ def ensure_time(maybe_time: Union[Time, str, int], int_unit: Union[TimeUnit, str return Time.from_timestring(maybe_time) if isinstance(maybe_time, int): return Time(maybe_time, int_unit) - if isinstance(maybe_time, Time): - return maybe_time - raise TypeError(f'Unsupported type for ensure_time: {type(maybe_time)}') \ No newline at end of file + return maybe_time diff --git a/simulation/core/simulation_dataset.py b/simulation/core/simulation_dataset.py index dac2c65b8..d1b0ba093 100644 --- a/simulation/core/simulation_dataset.py +++ b/simulation/core/simulation_dataset.py @@ -8,21 +8,23 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from streaming.base import StreamingDataset, Stream -from streaming.base.spanner import Spanner -from streaming.base.format import get_index_basename -from streaming.base.util import bytes_to_int, number_abbrev_to_int -from streaming.base.batching import generate_work -from typing import Optional, Sequence, Union, Tuple -from core.simulation_world import SimulationWorld -from core.simulation_spanner import SimulationSpanner +import os +import shutil +import time import warnings +from math import ceil +from typing import Optional, Sequence, Union + import numpy as np +from core.simulation_spanner import SimulationSpanner +from core.simulation_world import SimulationWorld from numpy.typing import NDArray -from math import ceil -import time -import os -import shutil + +from streaming.base import Stream, StreamingDataset +from streaming.base.batching import generate_work +from streaming.base.format import get_index_basename +from streaming.base.spanner import Spanner +from streaming.base.util import bytes_to_int, number_abbrev_to_int class SimulationDataset(StreamingDataset): @@ -118,17 +120,17 @@ def __init__(self, sampling_method: str = 'balanced', sampling_granularity: int = 1, batching_method: str = 'random') -> None: - + # Time how long it takes for StreamingDataset instantiation t0 = time.time() - + # Global arguments (which do not live in Streams). self.nodes = nodes self.devices = devices self.workers = workers self.cache_limit = cache_limit self.partition_algo = partition_algo - self.num_canonical_nodes = num_canonical_nodes or 64*nodes + self.num_canonical_nodes = num_canonical_nodes or 64 * nodes self.batch_size = batch_size or 1 self.predownload = predownload if predownload is not None \ else max(self.batch_size, 256 * self.batch_size // self.num_canonical_nodes) @@ -150,13 +152,12 @@ def __init__(self, raise ValueError( f'Invalid sampling method: {sampling_method}. Must be one of `balanced` or `fixed`.' ) - + # Check sampling method is one of "balanced" or "fixed". if self.batching_method not in ['random', 'per_stream', 'stratified']: raise ValueError( f'Invalid batching method: {batching_method}. Must be one of `random`, \ - `per_stream`, or `stratified`.' - ) + `per_stream`, or `stratified`.') # Check that predownload is at least per device batch size. if self.predownload < self.batch_size: @@ -170,7 +171,6 @@ def __init__(self, if epoch_size_value < 0: raise ValueError(f'Epoch size cannot be negative. Received {epoch_size_value}.') - # Initialize the Stream defaults and normalize to a list of Streams. if streams: default = { @@ -212,17 +212,19 @@ def __init__(self, else: filepath = os.path.join(stream.local, stream.split, get_index_basename()) # This suffix means a mock index file was created. Have to clean up later. - if stream.local.split('_')[-1] == "indexcreated": + if stream.local.split('_')[-1] == 'indexcreated': indices_created.append(2) else: # Index file is local. Don't delete later. indices_created.append(1) - self.stream_info[stream_idx] = {"path": filepath, - "local": stream.local, - "remote": stream.remote, - "proportion": stream._proportion, - "repeat": stream._repeat, - "choose": stream._choose} + self.stream_info[stream_idx] = { + 'path': filepath, + 'local': stream.local, + 'remote': stream.remote, + 'proportion': stream._proportion, + 'repeat': stream._repeat, + 'choose': stream._choose + } # Initialize the SimulationWorld, which tells us about nodes/devices/workers self.world = SimulationWorld(self.nodes, self.devices, self.workers) @@ -238,7 +240,7 @@ def __init__(self, index_filenames = [] local_foldernames = [] for stream_id, stream in enumerate(self.streams): - print("Processing index file for stream", stream_id+1) + print('Processing index file for stream', stream_id + 1) stream_shards = stream.get_shards(self.world) num_stream_samples = sum(map(len, stream_shards)) index_filename = os.path.join(stream.local, stream.split, get_index_basename()) @@ -266,7 +268,7 @@ def __init__(self, raise ValueError(f'Minimum cache usage ({min_cache_usage} bytes) is larger than ' + f'the cache limit ({self.cache_limit} bytes). Please raise ' + f'`cache_limit`.') - + for stream_idx, index_filename in enumerate(index_filenames): if indices_created[stream_idx] == 0: # Index file was downloaded from remote. @@ -281,7 +283,6 @@ def __init__(self, else: # Directory and index file were created. Delete both. shutil.rmtree(local_foldernames[stream_idx]) - # Build the shard index (for partitioning and mapping samples to shards). self.samples_per_shard = np.array([shard.samples for shard in self.shards], np.int64) @@ -289,13 +290,14 @@ def __init__(self, self.spanner = SimulationSpanner(self.samples_per_shard) # Also keep track of the raw and compressed sizes of each shard, indexed by shard_id. - self.raw_shard_sizes = np.array([shard.get_raw_size() for shard in self.shards]) - self.zip_shard_sizes = np.array([shard.get_zip_size() or 0 for shard in self.shards]) + self.raw_shard_sizes = np.array([shard.get_raw_size() for shard in self.shards], np.int64) + self.zip_shard_sizes = np.array([shard.get_zip_size() or 0 for shard in self.shards], + np.int64) - print("Total number of shards:", self.num_shards) - print("Average number of samples per shard:", self.num_samples/self.num_shards) - print("Average raw shard size (bytes):", np.mean(self.raw_shard_sizes)) - print("Average zip shard size (bytes):", np.mean(self.zip_shard_sizes)) + print('Total number of shards:', self.num_shards) + print('Average number of samples per shard:', self.num_samples / self.num_shards) + print('Average raw shard size (bytes):', np.mean(self.raw_shard_sizes)) + print('Average zip shard size (bytes):', np.mean(self.zip_shard_sizes)) # Now that we know the number of underlying samples of each stream, derive each stream's # true proportion/repeat/choose, as well as the total epoch size. @@ -306,11 +308,10 @@ def __init__(self, self.length = ceil(self.epoch_size / self.world.num_ranks) t1 = time.time() - self.instantiation_time = t1-t0 + self.instantiation_time = t1 - t0 - print("SimulationDataset created successfully.") + print('SimulationDataset created successfully.') - def get_sample_partition(self, epoch: int, sample_in_epoch: int) -> NDArray: """Get the dataset's partition of this epoch's sample space. @@ -322,7 +323,7 @@ def get_sample_partition(self, epoch: int, sample_in_epoch: int) -> NDArray: NDArray[np.int64]: Our partition of the epoch. """ return generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) - + def get_samples_per_node(self, epoch: int, sample_in_epoch: int) -> NDArray: """Get the dataset's number of samples per node, worker, device. @@ -336,7 +337,7 @@ def get_samples_per_node(self, epoch: int, sample_in_epoch: int) -> NDArray: partition = generate_work(self.batching_method, self, self.world, epoch, sample_in_epoch) # Modify partition to be in traversal order, per node, device, and worker. return partition.reshape(self.nodes, self.devices, self.workers, -1) - + def get_spanner(self) -> Spanner: """Get the dataset's spanner object, which does global sample id indexing. @@ -344,23 +345,23 @@ def get_spanner(self) -> Spanner: Spanner: The dataset's spanner object. """ return self.spanner - - def get_raw_shard_sizes(self) -> NDArray: + + def get_raw_shard_sizes(self) -> NDArray[np.int64]: """Get the dataset's raw shard sizes. Returns: NDArray[np.int64]: The dataset's raw shard sizes. """ return self.raw_shard_sizes - - def get_zip_shard_sizes(self) -> NDArray: + + def get_zip_shard_sizes(self) -> NDArray[np.int64]: """Get the dataset's zip shard sizes. Returns: NDArray[np.int64]: The dataset's zip shard sizes. """ return self.zip_shard_sizes - + def get_nodes(self) -> int: """Get the dataset's number of nodes. @@ -368,7 +369,7 @@ def get_nodes(self) -> int: int: The dataset's number of nodes. """ return self.nodes - + def get_devices(self) -> int: """Get the dataset's number of devices. @@ -376,7 +377,7 @@ def get_devices(self) -> int: int: The dataset's number of devices. """ return self.devices - + def get_workers(self) -> int: """Get the dataset's number of workers. @@ -384,7 +385,7 @@ def get_workers(self) -> int: int: The dataset's number of workers. """ return self.workers - + def get_num_canonical_nodes(self) -> int: """Get the dataset's number of canonical nodes. @@ -392,7 +393,7 @@ def get_num_canonical_nodes(self) -> int: int: The dataset's number of canonical nodes. """ return self.num_canonical_nodes - + def get_batch_size(self) -> int: """Get the dataset's batch size. @@ -400,7 +401,7 @@ def get_batch_size(self) -> int: int: The dataset's batch size. """ return self.batch_size - + def get_num_shards(self) -> int: """Get the dataset's number of shards. @@ -408,7 +409,7 @@ def get_num_shards(self) -> int: int: The dataset's number of shards. """ return self.num_shards - + def get_avg_samples_per_shard(self) -> int: """Get the dataset's average number of samples per shard. @@ -416,7 +417,7 @@ def get_avg_samples_per_shard(self) -> int: int: The dataset's average number of samples per shard. """ return round(self.num_samples / self.num_shards) - + def get_predownload(self) -> int: """Get the dataset's predownload. @@ -424,15 +425,17 @@ def get_predownload(self) -> int: int: The dataset's predownload. """ return self.predownload - + def get_cache_limit(self) -> Optional[int]: """Get the dataset's cache limit. Returns: - int: The dataset's cache limit. + Optional[int]: The dataset's cache limit. """ + if isinstance(self.cache_limit, str): + self.cache_limit = bytes_to_int(self.cache_limit) return self.cache_limit - + def get_instantiation_time(self) -> float: """Get the dataset's instantiation time. @@ -440,7 +443,7 @@ def get_instantiation_time(self) -> float: float: The dataset's instantiation time. """ return self.instantiation_time - + def get_num_batches(self) -> int: """Get the dataset's number of batches. @@ -448,7 +451,7 @@ def get_num_batches(self) -> int: int: The dataset's number of batches. """ return self.epoch_size // (self.batch_size * self.devices * self.nodes) - + def get_stream_info(self) -> dict: """Get the dataset's stream info. @@ -456,7 +459,7 @@ def get_stream_info(self) -> dict: dict: The dataset's stream info. """ return self.stream_info - + def get_shuffle(self) -> bool: """Get the dataset's shuffle. @@ -464,7 +467,7 @@ def get_shuffle(self) -> bool: bool: The dataset's shuffle. """ return self.shuffle - + def get_shuffle_algo(self) -> str: """Get the dataset's shuffle algorithm. @@ -472,7 +475,7 @@ def get_shuffle_algo(self) -> str: str: The dataset's shuffle algorithm. """ return self.shuffle_algo - + def get_shuffle_seed(self) -> int: """Get the dataset's shuffle seed. @@ -480,7 +483,7 @@ def get_shuffle_seed(self) -> int: int: The dataset's shuffle seed. """ return self.shuffle_seed - + def get_shuffle_block_size(self) -> int: """Get the dataset's shuffle block size. @@ -488,7 +491,7 @@ def get_shuffle_block_size(self) -> int: int: The dataset's shuffle block size. """ return self.shuffle_block_size - + def get_epoch_size(self) -> int: """Get the dataset's epoch size. @@ -496,7 +499,7 @@ def get_epoch_size(self) -> int: int: The dataset's epoch size. """ return self.epoch_size - + def get_sampling_method(self) -> str: """Get the dataset's sampling method. @@ -504,7 +507,7 @@ def get_sampling_method(self) -> str: str: The dataset's sampling method. """ return self.sampling_method - + def get_sampling_granularity(self) -> int: """Get the dataset's sampling granularity. @@ -512,7 +515,7 @@ def get_sampling_granularity(self) -> int: int: The dataset's sampling granularity. """ return self.sampling_granularity - + def get_batching_method(self) -> str: """Get the dataset's batching method. diff --git a/simulation/core/simulation_spanner.py b/simulation/core/simulation_spanner.py index 27da9ea96..7a1858267 100644 --- a/simulation/core/simulation_spanner.py +++ b/simulation/core/simulation_spanner.py @@ -4,6 +4,7 @@ """Mapping of global sample index to shard index for simulation purposes.""" from typing import Tuple + from streaming.base.spanner import Spanner diff --git a/simulation/core/simulation_world.py b/simulation/core/simulation_world.py index 383febaf3..6c607b8ad 100644 --- a/simulation/core/simulation_world.py +++ b/simulation/core/simulation_world.py @@ -5,8 +5,9 @@ from streaming.base.world import World + class SimulationWorld(World): - """Information about the nodes, ranks and workers of this run. + """Contains info about the nodes, ranks, and workers of the run, for simulation. Nodes are all assumed to contain the same number of devices (via local_world_size). @@ -24,25 +25,18 @@ class SimulationWorld(World): - worker_of_rank / workers_per_rank - is_leader - is_local_leader + Args: + nodes (int): The number of nodes. + devices (int): The number of devices per node. + workers (int): The number of workers per device. """ - def __init__(self, - nodes: int, - devices: int, - workers: int): - """Contains info about the nodes, ranks, and workers of the run, for simulation. - - Args: - nodes (int): The number of nodes. - devices (int): The number of devices per node. - workers (int): The number of workers per device. - """ - + def __init__(self, nodes: int, devices: int, workers: int): + # For simulation purposes, we take in the nodes, devices, and workers from the # SimulationDataset, and assume we are always rank 0 and worker 0. - self.rank = 0 - self.num_ranks = nodes*devices + self.num_ranks = nodes * devices self.ranks_per_node = devices self.rank_of_node = self.rank % self.ranks_per_node self.node = self.rank // self.ranks_per_node @@ -58,4 +52,4 @@ def __init__(self, self.workers_per_node = self.ranks_per_node * self.workers_per_rank self.is_leader = not self.worker - self.is_local_leader = not self.worker_of_node \ No newline at end of file + self.is_local_leader = not self.worker_of_node diff --git a/simulation/core/utils.py b/simulation/core/utils.py index 0c1ab58ae..e79b7345f 100644 --- a/simulation/core/utils.py +++ b/simulation/core/utils.py @@ -8,21 +8,21 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from typing import Optional, Tuple +import numpy as np from core.sim_time import Time, TimeUnit from core.simulation_dataset import SimulationDataset -import numpy as np from numpy.typing import NDArray -def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> Tuple[int, int, int]: + +def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> tuple[int, int, int]: """Get batches per epoch, epochs, and total epochs from a Time object. - Args: - dataset (SimulationDataset): The dataset being simulated. - max_duration (Time): The maximum duration, can be specified in yaml. + Args: + dataset (SimulationDataset): The dataset being simulated. + max_duration (Time): The maximum duration, can be specified in yaml. - Returns: - Tuple[int, int, int]: batches per epoch, epochs, and the total batches. + Returns: + tuple[int, int, int]: batches per epoch, epochs, and the total batches. """ # get epochs, batches_per_epoch, and total_batches from a Time obect dataset_batches = dataset.get_num_batches() @@ -45,19 +45,20 @@ def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> Tuple[ batches_per_epoch = dataset_batches total_batches = max_duration.value else: - raise ValueError("Simulator currently only supports max_duration in epochs or batches.") - + raise ValueError('Simulator currently only supports max_duration in epochs or batches.') + return batches_per_epoch, epochs, total_batches + def get_total_batches(dataset: SimulationDataset, max_duration: Time) -> int: """Get total batches from a Time object. - Args: - dataset (SimulationDataset): The dataset being simulated. - max_duration (Time): The maximum duration, can be specified in yaml. + Args: + dataset (SimulationDataset): The dataset being simulated. + max_duration (Time): The maximum duration, can be specified in yaml. - Returns: - int: The total batches. + Returns: + int: The total batches. """ dataset_batches = dataset.get_num_batches() total_batches = dataset_batches @@ -68,21 +69,23 @@ def get_total_batches(dataset: SimulationDataset, max_duration: Time) -> int: elif max_duration.unit == TimeUnit.BATCH: total_batches = max_duration.value else: - raise ValueError("Simulator currently only supports max_duration in epochs or batches.") - + raise ValueError('Simulator currently only supports max_duration in epochs or batches.') + return total_batches - + + def remove_padded_samples(samples: NDArray) -> NDArray: """Remove padded samples from a batch. - Args: - samples (NDArray): The samples to remove padded samples from. + Args: + samples (NDArray): The samples to remove padded samples from. - Returns: - NDArray: The samples with padded samples removed. + Returns: + NDArray: The samples with padded samples removed. """ return np.delete(samples, np.where(samples == -1)) + def bytes_to_time(bytes: int, bandwidth: int) -> float: """Convert bytes to time. @@ -95,6 +98,7 @@ def bytes_to_time(bytes: int, bandwidth: int) -> float: """ return bytes / bandwidth + def time_to_bytes(time: float, bandwidth: int) -> int: """Convert time to bytes. @@ -107,6 +111,7 @@ def time_to_bytes(time: float, bandwidth: int) -> int: """ return int(time * bandwidth) + def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray: """Get rolling average throughput from step times. @@ -119,10 +124,12 @@ def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray """ step_times_rolling_avg = np.convolve(step_times, np.ones(window) / window, mode='valid') batch_throughput_rolling_avg = 1 / step_times_rolling_avg - batch_throughput_rolling_avg = np.concatenate((np.array([0] * (window-1)), batch_throughput_rolling_avg)) + batch_throughput_rolling_avg = np.concatenate( + (np.array([0] * (window - 1)), batch_throughput_rolling_avg)) return batch_throughput_rolling_avg + def get_simulation_stats(step_times: NDArray, time_per_sample: float, device_batch_size: int) -> tuple[int, float, int, int]: """Gets simulation stats for web UI. @@ -136,10 +143,9 @@ def get_simulation_stats(step_times: NDArray, time_per_sample: float, tuple[int, float, int, int]: number of steps with throughput drops, time till warmup, step number of warmup, number of steps with throughput drops after warmup """ - # calculate percent of download-limited steps min_step_time = time_per_sample * device_batch_size - all_throughput_drops = np.count_nonzero(step_times > (min_step_time)) + all_throughput_drops = int(np.count_nonzero(step_times > (min_step_time))) epsilon = 1e-6 @@ -147,19 +153,19 @@ def get_simulation_stats(step_times: NDArray, time_per_sample: float, max_throughput = 1 / min_step_time rolling_avg_throughput = get_rolling_avg_throughput(step_times) if np.max(rolling_avg_throughput) >= max_throughput - epsilon: - warmup_step = np.argmax(rolling_avg_throughput >= (max_throughput)) + 1 - warmup_time = np.sum(step_times[:warmup_step]) + warmup_step = int(np.argmax(rolling_avg_throughput >= (max_throughput)) + 1) + warmup_time = float(np.sum(step_times[:warmup_step])) else: # we never hit the max possible throughput - warmup_step = rolling_avg_throughput.shape[0] - warmup_time = np.sum(step_times) - + warmup_step = int(rolling_avg_throughput.shape[0]) + warmup_time = float(np.sum(step_times)) + # see if there are throughput drops after warmup so we can notify users if warmup_step != rolling_avg_throughput.shape[0]: # if we did hit the max throughput then we check for later drops - post_warmup_throughput_drops = np.count_nonzero(step_times[warmup_step:] > min_step_time) + post_warmup_tp_drops = int(np.count_nonzero(step_times[warmup_step:] > min_step_time)) else: # since warmup was the whole time, there are no post-warmup throughput drops - post_warmup_throughput_drops = 0 - - return all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops \ No newline at end of file + post_warmup_tp_drops = 0 + + return all_throughput_drops, warmup_time, warmup_step, post_warmup_tp_drops diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index 89e8b1deb..99ecbf1dd 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -8,17 +8,19 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from typing import Optional + +from core.sim_time import Time, TimeUnit, ensure_time +from core.simulation_dataset import SimulationDataset from omegaconf import DictConfig from omegaconf import OmegaConf as om from omegaconf import SCMode -from core.simulation_dataset import SimulationDataset -from typing import Optional -from core.sim_time import Time, TimeUnit, ensure_time + from streaming.base import Stream -def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None - ) -> tuple[Optional[int], int, Time, int, dict]: +def ingest_yaml(yaml_dict: Optional[dict] = None, + filepath: Optional[str] = None) -> tuple[Optional[int], int, Time, int, dict]: """Create SimulationDataset from yaml file and other needed args. Args: @@ -26,25 +28,27 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None filepath (Optional[str]): path to yaml file Returns: - tuple[Optional[int], int, Time, int, dict]: total_devices, workers, max_duration, - global_batch_size, train_dataset parameters from yaml + tuple[Optional[int], Optional[int], Time, Optional[int], Optional[dict]]: total_devices, + workers, max_duration, global_batch_size, train_dataset parameters from yaml """ config = None # Read in the yaml file if filepath is not None: with open(filepath) as f: config = om.load(f) + if not isinstance(config, DictConfig): + raise ValueError('Yaml file must be a dictionary, not a list.') elif yaml_dict is not None: config = om.create(yaml_dict) else: - raise ValueError("Must specify either filepath or yaml_dict.") - + raise ValueError('Must specify either filepath or yaml_dict.') + # Get the number of devices (GPUs) - if 'compute' in config: - total_devices = config['compute']['gpus'] + if 'compute' in config and 'gpus' in config['compute']: + total_devices = int(config['compute']['gpus']) else: total_devices = None - + workers = None train_dataset = None max_duration = None @@ -52,7 +56,7 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None # Get the training and dataset params if 'parameters' in config: config = config['parameters'] - + # get global batch size if 'global_train_batch_size' in config: global_batch_size = config['global_train_batch_size'] @@ -69,7 +73,7 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None if 'dataset' in train_loader: train_dataset = train_loader['dataset'] else: - raise ValueError("dataset must be specified in the yaml file.") + raise ValueError('dataset must be specified in the yaml file.') elif 'dataset' in config: dataset = config['dataset'] if 'train_dataset' in dataset: @@ -77,38 +81,44 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath: Optional[str] = None if 'streaming_kwargs' in train_dataset: # Merge streaming kwargs, if present, into train_dataset train_dataset.update(train_dataset['streaming_kwargs']) - if 'dataloader_kwargs' in train_dataset and 'num_workers' in train_dataset['dataloader_kwargs']: + if 'dataloader_kwargs' in train_dataset and 'num_workers' in train_dataset[ + 'dataloader_kwargs']: workers = train_dataset['dataloader_kwargs']['num_workers'] else: workers = 1 else: - raise ValueError("train_dataset must be specified in the yaml file.") + raise ValueError('train_dataset must be specified in the yaml file.') else: - raise ValueError("train_loader or dataset must be specified in the yaml file.") - + raise ValueError('train_loader or dataset must be specified in the yaml file.') + # Get duration of training from config if 'max_duration' in config: max_duration = config['max_duration'] elif 'trainer' in config and 'max_duration' in config['trainer']: max_duration = config['trainer']['max_duration'] else: - raise ValueError("max_duration must be specified in the yaml file.") - + raise ValueError('max_duration must be specified in the yaml file.') + # convert max_duration to epochs or batches. max_duration = ensure_time(max_duration, TimeUnit.EPOCH) time_unit = max_duration.unit if time_unit != TimeUnit.EPOCH and time_unit != TimeUnit.BATCH: - raise ValueError("Simulator currently only supports max_duration in epochs or batches.") - - # convert train_dataset to dictionary from potentially a DictConfig + raise ValueError('Simulator currently only supports max_duration in epochs or batches.') + + # convert train_dataset to dict, if it isn't already if isinstance(train_dataset, DictConfig): train_dataset = om.to_container(train_dataset, resolve=False, throw_on_missing=True, - structured_config_mode=SCMode.INSTANTIATE) - + structured_config_mode=SCMode.DICT) + + assert isinstance(workers, int), 'workers must be an integer.' + assert isinstance(global_batch_size, int), 'global_batch_size must be an integer.' + assert isinstance(train_dataset, dict), 'train_dataset must be a dict.' + return total_devices, workers, max_duration, global_batch_size, train_dataset + def create_simulation_dataset(nodes: int, devices: int, workers: int, global_batch_size: int, train_dataset: dict) -> SimulationDataset: """Create SimulationDataset from input information. @@ -129,7 +139,7 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat if isinstance(train_dataset['local'], list) \ and isinstance(train_dataset['remote'], list): if len(train_dataset['local']) != len(train_dataset['remote']): - raise ValueError("local and remote must be the same length in the yaml file.") + raise ValueError('local and remote must be the same length in the yaml file.') streams = [] for local, remote in zip(train_dataset['local'], train_dataset['remote']): streams.append(Stream(local=local, remote=remote, split=train_dataset['split'] \ @@ -142,13 +152,13 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat streams_dict = train_dataset.get('streams', None) if streams_dict is not None: streams = [] - streams_dict = om.to_object(streams_dict) - for _, stream in streams_dict.items(): - if "path" in stream: - del stream["path"] + assert isinstance(streams_dict, dict), 'streams must be a dict if not a list.' + for stream in streams_dict.values(): + if 'path' in stream: + del stream['path'] # Create Stream object from each dictionary entry streams.append(Stream(**stream)) - + remote = train_dataset.get('remote', None) local = train_dataset.get('local', None) split = train_dataset.get('split', None) @@ -161,9 +171,9 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat cache_limit = train_dataset.get('cache_limit', None) partition_algo = train_dataset.get('partition_algo', 'orig') num_canonical_nodes = train_dataset.get('num_canonical_nodes', None) - if global_batch_size % (devices*nodes) != 0: - raise ValueError("global_batch_size must be divisible by total number of devices.") - batch_size = global_batch_size // (devices*nodes) + if global_batch_size % (devices * nodes) != 0: + raise ValueError('global_batch_size must be divisible by total number of devices.') + batch_size = global_batch_size // (devices * nodes) shuffle = train_dataset.get('shuffle', False) shuffle_algo = train_dataset.get('shuffle_algo', 'py1s') shuffle_seed = train_dataset.get('shuffle_seed', 9176) @@ -171,13 +181,12 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat sampling_method = train_dataset.get('sampling_method', 'balanced') sampling_granularity = train_dataset.get('sampling_granularity', 1) batching_method = train_dataset.get('batching_method', 'random') - + dataset = SimulationDataset(nodes, devices, workers, streams, remote, local, split, download_retry, download_timeout, validate_hash, keep_zip, epoch_size, predownload, cache_limit, partition_algo, num_canonical_nodes, batch_size, shuffle, shuffle_algo, - shuffle_seed, shuffle_block_size, sampling_method, + shuffle_seed, shuffle_block_size, sampling_method, sampling_granularity, batching_method) - - return dataset + return dataset diff --git a/simulation/interfaces/interface_utils.py b/simulation/interfaces/interface_utils.py index 7a166bf63..b145aa32c 100644 --- a/simulation/interfaces/interface_utils.py +++ b/simulation/interfaces/interface_utils.py @@ -8,24 +8,22 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -import numpy as np -from numpy.typing import NDArray from typing import Optional + +import numpy as np from core.utils import get_rolling_avg_throughput +from numpy.typing import NDArray + from streaming.base.util import number_abbrev_to_int -def plot_simulation(step_times: NDArray, - step_downloads: NDArray, - window: int = 10): + +def plot_simulation(step_times: NDArray, step_downloads: NDArray, window: int = 10): """Plots simulation results for web UI or local script. Args: step_times (NDArray): time per step, as calculated by simulation step_downloads (NDArray): download size (bytes) per step, as calculated by simulation window (int, optional): window size to calculate batch throughput over. Defaults to ``10``. - - Returns: - Optional[bytes]: bytes of plot image if ``web`` is ``True``, else plot is displayed, and returns ``None``. """ import matplotlib.pyplot as plt @@ -67,28 +65,36 @@ def plot_simulation(step_times: NDArray, plt.show() -def get_train_dataset_params(input_params: dict, create_indices: bool = False, - old_params: Optional[dict] = None) -> dict: + +def get_train_dataset_params(input_params: dict, old_params: Optional[dict] = None) -> dict: + """Get train dataset params from input params. + + Args: + input_params (dict): The input parameter dictionary set by the user. + old_params (Optional[dict], optional): Old parameters that may have been read in. + Defaults to ``None``. + + Returns: + dict: The full train dataset parameters. + """ train_dataset_params = {} - train_dataset_params["epoch_size"] = input_params["epoch_size"] - train_dataset_params["batch_size"] = input_params["device_batch_size"] - train_dataset_params["nodes"] = input_params["physical_nodes"] - train_dataset_params["devices"] = input_params["devices"] - train_dataset_params["workers"] = input_params["workers"] - train_dataset_params["num_canonical_nodes"] = input_params["canonical_nodes"] - train_dataset_params["predownload"] = input_params["predownload"] - train_dataset_params["cache_limit"] = input_params["cache_limit"] - train_dataset_params["shuffle"] = input_params["shuffle"] - train_dataset_params["shuffle_algo"] = input_params["shuffle_algo"] - train_dataset_params["shuffle_block_size"] = number_abbrev_to_int( - input_params["shuffle_block_size"]) - train_dataset_params["shuffle_seed"] = input_params["seed"] - train_dataset_params["sampling_method"] = input_params["sampling_method"] - train_dataset_params["sampling_granularity"] = input_params["sampling_granularity"] - train_dataset_params["batching_method"] = input_params["batching_method"] - if create_indices: - train_dataset_params["indices_created"] = True - + train_dataset_params['epoch_size'] = input_params['epoch_size'] + train_dataset_params['batch_size'] = input_params['device_batch_size'] + train_dataset_params['nodes'] = input_params['physical_nodes'] + train_dataset_params['devices'] = input_params['devices'] + train_dataset_params['workers'] = input_params['workers'] + train_dataset_params['num_canonical_nodes'] = input_params['canonical_nodes'] + train_dataset_params['predownload'] = input_params['predownload'] + train_dataset_params['cache_limit'] = input_params['cache_limit'] + train_dataset_params['shuffle'] = input_params['shuffle'] + train_dataset_params['shuffle_algo'] = input_params['shuffle_algo'] + train_dataset_params['shuffle_block_size'] = number_abbrev_to_int( + input_params['shuffle_block_size']) + train_dataset_params['shuffle_seed'] = input_params['seed'] + train_dataset_params['sampling_method'] = input_params['sampling_method'] + train_dataset_params['sampling_granularity'] = input_params['sampling_granularity'] + train_dataset_params['batching_method'] = input_params['batching_method'] + # If there were old params, fill them in. if old_params is not None: existing_params_set = set(train_dataset_params.keys()) @@ -99,6 +105,6 @@ def get_train_dataset_params(input_params: dict, create_indices: bool = False, train_dataset_params[param] = old_params[param] else: # If there are no old params, we need to set streams to what the user provided. - train_dataset_params["streams"] = input_params["streams"] - + train_dataset_params['streams'] = input_params['streams'] + return train_dataset_params diff --git a/simulation/interfaces/simulation_script.py b/simulation/interfaces/sim_script.py similarity index 62% rename from simulation/interfaces/simulation_script.py rename to simulation/interfaces/sim_script.py index 1d7c164ea..ba9621c89 100644 --- a/simulation/interfaces/simulation_script.py +++ b/simulation/interfaces/sim_script.py @@ -8,15 +8,14 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from core.create_index import create_stream_index from core.main import simulate +from core.sim_time import TimeUnit, ensure_time from core.simulation_dataset import SimulationDataset -from core.create_index import create_stream_index -from core.sim_time import ensure_time, TimeUnit from core.utils import get_simulation_stats -import matplotlib.pyplot as plt -import numpy as np -from streaming.base import Stream from interfaces.interface_utils import plot_simulation +import humanize +from streaming.base import Stream # Input Parameters @@ -24,11 +23,11 @@ shards = 20850 # number of shards samples_per_shard = 4093 # number of samples per shard avg_raw_shard_size = 67092639 # average shard size (bytes) -avg_zip_shard_size = None # average compressed shard size (bytes) +avg_zip_shard_size = None # average compressed shard size (bytes) # training -max_duration = "1000ba" # max duration of training (batches: "ba", epochs: "ep") -epoch_size = None # epoch size (samples) +max_duration = '1000ba' # max duration of training (batches: "ba", epochs: "ep") +epoch_size = None # epoch size (samples) device_batch_size = 16 # device batch size (samples) # streaming @@ -36,7 +35,7 @@ canonical_nodes = 2 # number of canonical nodes predownload = 32 # number of samples to predownload per worker (samples) cache_limit = None # cache limit (bytes) -shuffle = True # whether to shuffle dataset +shuffle = True # whether to shuffle dataset shuffle_algo = 'py1b' # shuffling algorithm shuffle_block_size = 16000000 # shuffling block size (samples) seed = 17 # random seed @@ -51,42 +50,43 @@ # instantiate SimulationDataset on the same parameters for the new simulation function -stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, +stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, avg_zip_shard_size) stream_folder = os.path.dirname(stream_indexpath) stream = Stream(local=stream_folder) max_duration = ensure_time(max_duration, TimeUnit.EPOCH) -dataset = SimulationDataset( - nodes=physical_nodes, - devices=devices, - workers=workers, - streams=[stream], - epoch_size=epoch_size, - predownload=predownload, - cache_limit=cache_limit, - num_canonical_nodes=canonical_nodes, - batch_size=device_batch_size, - shuffle=True, - shuffle_algo=shuffle_algo, - shuffle_seed=seed, - shuffle_block_size=shuffle_block_size -) - -results = simulate(dataset=dataset, +dataset = SimulationDataset(nodes=physical_nodes, + devices=devices, + workers=workers, + streams=[stream], + epoch_size=epoch_size, + predownload=predownload, + cache_limit=cache_limit, + num_canonical_nodes=canonical_nodes, + batch_size=device_batch_size, + shuffle=True, + shuffle_algo=shuffle_algo, + shuffle_seed=seed, + shuffle_block_size=shuffle_block_size) + +node_internet_bandwidth = int(node_internet_bandwidth) +results = next( + simulate(dataset=dataset, time_per_sample=time_per_sample, node_network_bandwidth=node_internet_bandwidth, - max_duration=max_duration) + max_duration=max_duration)) -step_times, step_downloads, startup_time, min_cache_limit = next(results) +assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' +step_times, step_downloads, startup_time, min_cache_limit = results global_batch_size = device_batch_size * devices * physical_nodes # Display simulation stats total_batches = len(step_times) all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ get_simulation_stats(step_times, time_per_sample, global_batch_size//(physical_nodes*devices)) -print("\nSimulation Stats:") -print(f"Minimum cache limit needed: {min_cache_limit:,} bytes") +print('\nSimulation Stats:') +print(f'Minimum cache limit needed: {humanize.naturalsize(min_cache_limit)}') if cache_limit is not None and cache_limit < min_cache_limit: # Cache limit is too low, and will cause shard redownloads / throughput drops. print('⚠️ The provided cache limit is lower than the minimum cache limit needed to \ @@ -97,15 +97,14 @@ performant.') elif post_warmup_throughput_drops: # display warning if post-warmup throughput drops are more than 10% of the run. - print('⚠️ This configuration experiences some downloading-related slowdowns even after \ - warmup.') -print("{0} steps, or {1:.1f}% of all steps, waited for shard \ - downloads.".format(all_throughput_drops, 100*all_throughput_drops/(total_batches))) + print('⚠️ This configuration experiences some downloading-related slowdowns even after warmup.') +print('{0} steps, or {1:.1f}% of all steps, waited for shard downloads.'\ + .format(all_throughput_drops, 100 * all_throughput_drops / (total_batches))) if warmup_step != total_batches: # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) - print("There were {} steps that waited for shard downloads after the warmup \ - period.".format(post_warmup_throughput_drops)) -print("Estimated time to first batch: {0:.2f} s".format(startup_time)) -print("Estimated warmup time: {0:.2f} s".format(warmup_time)) + print('There were {} steps that waited for shard downloads after the warmup period.'\ + .format(post_warmup_throughput_drops)) +print('Estimated time to first batch: {0:.2f} s'.format(startup_time)) +print('Estimated warmup time: {0:.2f} s'.format(warmup_time)) -plot_simulation(step_times, step_downloads) \ No newline at end of file +plot_simulation(step_times, step_downloads) diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py new file mode 100644 index 000000000..52d74189d --- /dev/null +++ b/simulation/interfaces/sim_ui.py @@ -0,0 +1,397 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Simulator web UI using streamlit.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +from concurrent.futures import ProcessPoolExecutor +from io import StringIO +from typing import Union + +import numpy as np +import pandas as pd +import streamlit as st +import yaml +from core.create_index import create_stream_index +from core.main import simulate +from core.shuffle_quality import analyze_shuffle_quality +from core.sim_time import Time +from core.simulation_dataset import SimulationDataset +from core.utils import get_total_batches +from core.yaml_processing import create_simulation_dataset, ingest_yaml +from interfaces.interface_utils import get_train_dataset_params +from interfaces.widgets import (display_shuffle_quality_graph, display_simulation_stats, + get_line_chart, param_inputs) + +from streaming.base.util import bytes_to_int, number_abbrev_to_int + +# set up page +st.set_page_config(layout='wide') +col1, space, col2 = st.columns((10, 1, 6)) +col2.title('Streaming Simulator') +col2.write('Enter run parameters in the left panel.') +col2.text('') +progress_bar = col1.progress(0) +status_text = col1.empty() +col1.text('') +throughput_plot = col2.empty() +network_plot = col2.empty() +sim_stats = col2.empty() +col2.text('') +shuffle_quality_plot = col2.empty() +throughput_window = 10 +shuffle_quality_algos = ['naive', 'py1b', 'py1br', 'py1e', 'py1s', 'py2s', 'none'] + + +def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_sample: float, + node_internet_bandwidth: Union[int, str], max_duration: Time): + """Submit jobs to the executor for simulation. + + Args: + shuffle_quality (bool): Whether to run shuffle quality analysis. + dataset (SimulationDataset): Dataset to use for simulation. + time_per_sample (float): Time for one device to process one sample. + node_internet_bandwidth (Union[int,str]): Internet bandwidth per node. + max_duration (Time): Maximum duration of simulation. + """ + total_batches = get_total_batches(dataset=dataset, max_duration=max_duration) + node_internet_bandwidth = bytes_to_int(node_internet_bandwidth) + cache_limit = dataset.get_cache_limit() + gen_sim = simulate(dataset, + time_per_sample, + node_internet_bandwidth, + max_duration, + generator=True) + gen_step_times = [] + gen_step_downloads = [] + rolling_throughput_data = [] + immediate_throughput_data = [] + network_data = [] + steps = [] + time_to_first_batch = 0 + futures = [] + shuffle_quality_graphed = False + # Initialize min_cache_limit to be 0. Will be replaced by the simulated value. + min_cache_limit = 0 + # Define partial function to pass to executor map for simulation. + with ProcessPoolExecutor(max_workers=8) as executor: + # Submit shuffle quality job to executor. + if shuffle_quality: + col1.write('Starting shuffle quality analysis...') + input_params = st.session_state['input_params'] + # Use multiprocessing to get the shuffle quality results. + canonical_nodes = input_params['canonical_nodes'] + physical_nodes = input_params['physical_nodes'] + devices = input_params['devices'] + workers = input_params['workers'] + device_batch_size = input_params['device_batch_size'] + shuffle_block_size = number_abbrev_to_int(input_params['shuffle_block_size']) + samples_per_shard = dataset.get_avg_samples_per_shard() + epoch_size = dataset.get_epoch_size() + if epoch_size > 100000000: + st.warning('Epoch size is over 100 million samples. Shuffle quality analysis \ + will be conducted only on the first 100 million samples.', + icon='⚠️') + seed = input_params['seed'] + # Submit all shuffle quality analysis jobs to executor. + futures = [ + executor.submit(analyze_shuffle_quality, algo, canonical_nodes, physical_nodes, + devices, workers, device_batch_size, shuffle_block_size, + samples_per_shard, epoch_size, seed) + for algo in shuffle_quality_algos + ] + + # Simulate only on the main worker, otherwise it's super slow. + for output in gen_sim: + # If output is a length 2, it is the time to first batch and min cache limit. + # Otherwise it is the step, step time, and shard download from the simulation. + if len(output) == 2: + step = total_batches - 1 + time_to_first_batch, min_cache_limit = output + else: + assert len(output) == 3, 'Simulation with generate=True should return 3 results \ + per step.' + + step, step_time, shard_download = output + gen_step_times.append(step_time) + gen_step_downloads.append(shard_download) + # plot throughput once we have enough samples for the window + rolling_throughput = 0 + if step >= throughput_window - 1: + step_time_window = np.array(gen_step_times[-throughput_window:]) + rolling_throughput = 1 / np.mean((step_time_window)) + rolling_throughput_data.append(rolling_throughput) + immediate_throughput_data.append(1 / step_time) + # plot network usage + cumulative_shard_download = np.sum(np.array(gen_step_downloads)) + network_data.append(cumulative_shard_download) + steps.append(step + 1) + + # update plots and percentages at regular intervals + plot_interval = (total_batches) // 15 + if step == 1 or step % plot_interval == 0 or step == total_batches - 1: + rolling_throughput_df = pd.DataFrame({ + 'step': steps, + 'measurement': [' rolling avg'] * len(rolling_throughput_data), + 'throughput (batches/s)': rolling_throughput_data + }) + throughput_df = rolling_throughput_df + network_df = pd.DataFrame({ + 'step': steps, + 'cumulative network usage (bytes)': network_data + }) + throughput_plot.altair_chart(get_line_chart(throughput_df, throughput_window, + True), + use_container_width=True) + network_plot.altair_chart(get_line_chart(network_df, throughput_window, False), + use_container_width=True) + # update progress bar and text + percentage = int(100 * (step + 1) / (total_batches)) + status_text.text('%i%% Complete' % percentage) + progress_bar.progress(percentage) + + # If applicable, check if the shuffle quality tasks are finished, and graph. + if shuffle_quality and all(f.done() for f in futures) \ + and not shuffle_quality_graphed: + display_shuffle_quality_graph(futures, shuffle_quality_plot) + shuffle_quality_graphed = True + + gen_step_times = np.array(gen_step_times) + gen_step_downloads = np.array(gen_step_downloads) + device_batch_size = dataset.get_batch_size() + display_simulation_stats(sim_stats, total_batches, gen_step_times, time_per_sample, + device_batch_size, time_to_first_batch, min_cache_limit, + cache_limit) + + # If shuffle quality still hasn't been graphed yet, we get the result and graph it. + if shuffle_quality and not shuffle_quality_graphed: + display_shuffle_quality_graph(futures, shuffle_quality_plot) + shuffle_quality_graphed = True + + +def get_input_params_initial(physical_nodes: int, devices: int, workers: int, + global_batch_size: int, train_dataset: dict, max_duration: Time, + time_per_sample: float, node_internet_bandwidth: Union[int, str]): + """Create input_params and dataset for the first time. + + This function is called when modify_params is clicked, or when the run is submitted for + simulation when using a yaml file. + + Args: + physical_nodes (int): Number of physical nodes. + devices (int): Number of devices per node. + workers (int): Number of workers. + global_batch_size (int): Global batch size. + train_dataset (dict): Train dataset parameters. + max_duration (Time): Maximum duration of simulation. + time_per_sample (float): Time for one device to process one sample. + node_internet_bandwidth (Union[int,str]): Internet bandwidth per node. + """ + try: + st.session_state['creating_dataset'] = True + dataset = create_simulation_dataset(physical_nodes, devices, workers, global_batch_size, + train_dataset) + st.session_state['orig_dataset'] = dataset + input_params = {} + # dataset input_params + input_params['streams'] = dataset.get_stream_info() + # training input_params + input_params['max_duration'] = max_duration + input_params['epoch_size'] = dataset.get_epoch_size() + input_params['device_batch_size'] = dataset.get_batch_size() + # hardware and network input_params + input_params['physical_nodes'] = physical_nodes + input_params['devices'] = devices + input_params['time_per_sample'] = time_per_sample + input_params['node_network_bandwidth'] = node_internet_bandwidth + # streaming input_params + input_params['workers'] = workers + input_params['canonical_nodes'] = dataset.get_num_canonical_nodes() + input_params['predownload'] = dataset.get_predownload() + input_params['shuffle'] = dataset.get_shuffle() + input_params['shuffle_algo'] = dataset.get_shuffle_algo() + input_params['shuffle_block_size'] = dataset.get_shuffle_block_size() + input_params['seed'] = dataset.get_shuffle_seed() + input_params['cache_limit'] = dataset.get_cache_limit() + input_params['sampling_method'] = dataset.get_sampling_method() + input_params['sampling_granularity'] = dataset.get_sampling_granularity() + input_params['batching_method'] = dataset.get_batching_method() + # Save input_params and originally created dataset to session state. + st.session_state['input_params'] = input_params + except FileNotFoundError: + st.error('Please wait until the dataset is loaded before changing toggle values too \ + quickly. Doing so can cause issues with creating multiple datasets, since \ + Streamlit reloads widgets every single time a toggle value changes.', + icon='🚨') + + +# Define parameter input area. + +# Check if the user wants to submit a yaml file. +use_yaml = col1.toggle(':sparkles: **Use `yaml`** :sparkles:', value=True) + +if use_yaml: + uploaded_yaml = col1.file_uploader('Upload a yaml file', type=['yaml']) + if uploaded_yaml is not None: + string_yaml = StringIO(uploaded_yaml.getvalue().decode('utf-8')).read() + dict_yaml = yaml.safe_load(string_yaml) + total_devices, workers, max_duration, global_batch_size, train_dataset = \ + ingest_yaml(yaml_dict=dict_yaml) + # Check which parameters we still need to ask for. + col1.write('The parameters below were not found in your yaml file. Enter them here:') + physical_nodes = col1.number_input( + 'number of physical nodes', + step=1, + value=1, + help= + 'number of physical nodes for this run. a node typically consists of 8 devices (GPUs).' + ) + physical_nodes = int(physical_nodes) + # Using physical_nodes, calculate number of devices per node. + if total_devices is None: + devices = col1.number_input( + 'devices per node', + step=1, + value=8, + help= + 'number of devices (GPUs) per node for this run. there are typically 8 devices per node.' + ) + else: + if total_devices % physical_nodes != 0: + raise ValueError('The number of devices must be divisible by the number of nodes.') + devices = total_devices // physical_nodes + devices = int(devices) + time_per_sample = col1.number_input( + 'process time per sample (s)', + step=0.0005, + value=0.0175, + format='%.4f', + help='time for one device to process one sample from your dataset.') + time_per_sample = float(time_per_sample) + node_internet_bandwidth = col1.text_input('network bandwidth per node (bytes/s)', + value='1GB', + help='network bandwidth available to each \ + node. in practice, network bandwidth is \ + variable and is affected by many factors, \ + including cluster demand.') + + submitted = col1.button('Simulate Run', use_container_width=True) + shuffle_quality = col1.toggle('Analyze Shuffle Quality', + value=False, + help='Analyze shuffle qualities for this run for different \ + shuffle algos using an entropy-based metric. ⚠️ **Results \ + are *noisy estimates* and may not reflect the true \ + shuffle quality.**') + modify_params = col1.toggle('Modify Parameters', value=False) + + # Display components and take actions based on the values of the above three buttons. + if modify_params: + # Create dataset and input_params if it doesn't already exist. + if 'input_params' not in st.session_state: + col1.write('Preparing dataset for modification...') + get_input_params_initial(physical_nodes, devices, workers, global_batch_size, + train_dataset, max_duration, time_per_sample, + node_internet_bandwidth) + # We have input_params in the session state. Use it to populate the form. + defaults = st.session_state['input_params'] + # Define parameter input area with default values. + input_params = {} + param_inputs(col1, input_params, defaults=defaults) + # input_params has been repopulated with new values. Save to session state. + st.session_state['input_params'] = input_params + + if submitted: + # Create dataset if it is not yet present. + if 'input_params' not in st.session_state: + col1.write('Preparing dataset for this run...') + get_input_params_initial(physical_nodes, devices, workers, global_batch_size, + train_dataset, max_duration, time_per_sample, + node_internet_bandwidth) + # If modify_params is false, we submit the jobs using the original dataset from yaml. + if not modify_params: + col1.write('Starting Simulation...') + dataset = st.session_state['orig_dataset'] + # shuffle_quality is passed through to the job submission function. + submit_jobs(shuffle_quality, dataset, time_per_sample, node_internet_bandwidth, + max_duration) + else: + # If modify_params is true, we retrieve the most recent input params from session + # state, create a new dataset, and submit the jobs. + col1.write('Preparing dataset with modifications...') + # Get parameters for new SimulationDataset from input_params and train_dataset. + input_params = st.session_state['input_params'] + train_dataset = get_train_dataset_params(input_params, old_params=train_dataset) + # Get the rest of the needed params from the new inputs + physical_nodes = input_params['physical_nodes'] + devices = input_params['devices'] + global_batch_size = input_params['device_batch_size'] * devices * physical_nodes + workers = input_params['workers'] + max_duration = input_params['max_duration'] + time_per_sample = input_params['time_per_sample'] + node_internet_bandwidth = input_params['node_network_bandwidth'] + # Make sure node_internet_bandwidth is an int. + dataset = create_simulation_dataset(physical_nodes, devices, workers, + global_batch_size, train_dataset) + col1.write('Starting Simulation...') + submit_jobs(shuffle_quality, dataset, time_per_sample, node_internet_bandwidth, + max_duration) +else: + submitted = col1.button('Simulate Run', use_container_width=True) + col1.text('') + shuffle_quality = col1.toggle('Analyze Shuffle Quality', + value=False, + help='Analyze shuffle qualities for this run for different \ + shuffle algos using an entropy-based metric. ⚠️ **Results \ + are *noisy estimates* and may not reflect the true \ + shuffle quality.**') + if 'input_params' in st.session_state: + st.session_state['input_params'] = {} + input_params = {} + param_inputs(col1, input_params, defaults=input_params) + if submitted: + # Params have been submitted. Create new dataset and proceed with simulation. + col1.write('Preparing dataset for this run...') + # Create index files and Stream object for each stream. + streams = {} + for stream_idx, stream in input_params['streams'].items(): + stream_dict = {} + if 'path' in stream: + # Case when user has provided a path to an index.json file. + stream_folder = os.path.dirname(stream['path']) + if stream['path_type'] == 'local': + stream_dict['local'] = stream_folder + else: + stream_dict['remote'] = stream_folder + else: + # Case when user provides estimates for stream characteristics. + index_path = create_stream_index(stream['shards'], stream['samples_per_shard'], + stream['avg_raw_shard_size'], + stream['avg_zip_shard_size']) + stream_folder = os.path.dirname(index_path) + stream_dict['local'] = stream_folder + stream_dict['proportion'] = stream['proportion'] + stream_dict['repeat'] = stream['repeat'] + stream_dict['choose'] = stream['choose'] + streams[stream_idx] = stream_dict + input_params['streams'] = streams + # Get parameters for new SimulationDataset from input_params and train_dataset. + train_dataset = get_train_dataset_params(input_params) + # Get the rest of the needed params from the new inputs + physical_nodes = input_params['physical_nodes'] + devices = input_params['devices'] + global_batch_size = input_params['device_batch_size'] * devices * physical_nodes + workers = input_params['workers'] + max_duration = input_params['max_duration'] + time_per_sample = input_params['time_per_sample'] + node_internet_bandwidth = input_params['node_network_bandwidth'] + dataset = create_simulation_dataset(physical_nodes, devices, workers, global_batch_size, + train_dataset) + # Make sure input_params is in session state. + st.session_state['input_params'] = input_params + col1.write('Starting Simulation...') + submit_jobs(shuffle_quality, dataset, time_per_sample, node_internet_bandwidth, + max_duration) diff --git a/simulation/interfaces/simcli.py b/simulation/interfaces/simcli.py index ceca7f8c7..6fb626f67 100644 --- a/simulation/interfaces/simcli.py +++ b/simulation/interfaces/simcli.py @@ -8,71 +8,91 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import argparse + from core.main import simulate -from interfaces.interface_utils import plot_simulation from core.utils import get_simulation_stats -import argparse -from core.yaml_processing import ingest_yaml, create_simulation_dataset +from core.yaml_processing import create_simulation_dataset, ingest_yaml +from interfaces.interface_utils import plot_simulation +import humanize from streaming.base.util import bytes_to_int if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Simulate your training yaml from the command line.') + parser = argparse.ArgumentParser(description='Simulate your training yaml from the command \ + line.') parser.add_argument('-f', '--file', type=str, help='path to yaml file', required=True) parser.add_argument('-n', '--nodes', type=int, help='number of physical nodes', required=False) - parser.add_argument('-d', '--devices', type=int, help='number of devices per node', required=False) - parser.add_argument('-t', '--time_per_sample', type=float, help='time to process one sample on one device (seconds)', required=False) - parser.add_argument('-b', '--node_internet_bandwidth', type=str, help='internet bandwidth per node (bytes/s)', required=False) + parser.add_argument('-d', + '--devices', + type=int, + help='number of devices per node', + required=False) + parser.add_argument('-t', + '--time_per_sample', + type=float, + help='time to process one sample on one device (seconds)', + required=False) + parser.add_argument('-b', + '--node_internet_bandwidth', + type=str, + help='internet bandwidth per node (bytes/s)', + required=False) args = parser.parse_args() # Read in yaml file filepath = args.file - total_devices, workers, max_duration, global_batch_size, train_dataset = ingest_yaml(filepath=filepath) + total_devices, workers, max_duration, global_batch_size, train_dataset = \ + ingest_yaml(filepath=filepath) # Check if we have to ask for any parameters args = parser.parse_args() nodes = args.nodes if nodes is None: - nodes = int(input("Number of physical nodes: ")) + nodes = int(input('Number of physical nodes: ')) # devices may be specified in the yaml file. if total_devices is None: devices = args.devices else: if total_devices % nodes != 0: - raise ValueError("The number of devices must be divisible by the number of nodes.") + raise ValueError('The number of devices must be divisible by the number of nodes.') devices = total_devices // nodes time_per_sample = args.time_per_sample node_network_bandwidth = args.node_internet_bandwidth if devices is None: - devices = int(input("Number of devices per node: ")) + devices = int(input('Number of devices per node: ')) if time_per_sample is None: - time_per_sample = float(input("Time to process one sample on one device (seconds): ")) + time_per_sample = float(input('Time to process one sample on one device (seconds): ')) if node_network_bandwidth is None: - bandwidth_input = input("Internet bandwidth per node (bytes/s): ") + bandwidth_input = input('Internet bandwidth per node (bytes/s): ') try: + # Converting to float first handles the case where the input is a string in scientific + # notation, like "1e9". node_network_bandwidth = float(bandwidth_input) + node_network_bandwidth = int(node_network_bandwidth) except ValueError: - node_network_bandwidth = bandwidth_input - + node_network_bandwidth = str(bandwidth_input) + # Convert strings into numbers for applicable args node_network_bandwidth = bytes_to_int(node_network_bandwidth) # Create SimulationDataset - print("Constructing SimulationDataset...") + print('Constructing SimulationDataset...') dataset = create_simulation_dataset(nodes, devices, workers, global_batch_size, train_dataset) # Simulate Run - step_times, step_downloads, startup_time, min_cache_limit = next( - simulate(dataset, time_per_sample, node_network_bandwidth, max_duration=max_duration)) + results = next(simulate(dataset, time_per_sample, node_network_bandwidth, max_duration)) + assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' + step_times, step_downloads, startup_time, min_cache_limit = results - print("Simulation Finished.") + print('Simulation Finished.') # Display simulation stats total_batches = len(step_times) cache_limit = dataset.get_cache_limit() all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ get_simulation_stats(step_times, time_per_sample, global_batch_size//(nodes*devices)) - print("\nSimulation Stats:") - print(f"Minimum cache limit needed: {min_cache_limit:,} bytes") + print('\nSimulation Stats:') + print(f'Minimum cache limit needed: {humanize.naturalsize(min_cache_limit)}') if cache_limit is not None and cache_limit < min_cache_limit: # Cache limit is too low, and will cause shard redownloads / throughput drops. print('⚠️ The provided cache limit is lower than the minimum cache limit needed to \ @@ -85,14 +105,15 @@ # display warning if post-warmup throughput drops are more than 10% of the run. print('⚠️ This configuration experiences some downloading-related slowdowns even after \ warmup.') - print("{0} steps, or {1:.1f}% of all steps, waited for shard \ - downloads.".format(all_throughput_drops, 100*all_throughput_drops/(total_batches))) + print('{0} steps, or {1:.1f}% of all steps, waited for shard downloads.'\ + .format(all_throughput_drops, 100 * all_throughput_drops / (total_batches))) if warmup_step != total_batches: - # only display post-warmup throughput drop info if we actually ended the warmup period (i.e. we hit peak throughput at some point) - print("There were {} steps that waited for shard downloads after the warmup \ - period.".format(post_warmup_throughput_drops)) - print("Estimated time to first batch: {0:.2f} s".format(startup_time)) - print("Estimated warmup time: {0:.2f} s".format(warmup_time)) + # only display post-warmup throughput drop info if we actually ended the warmup period + # (i.e. we hit peak throughput at some point) + print('There were {} steps that waited for shard downloads after the warmup period.'\ + .format(post_warmup_throughput_drops)) + print('Estimated time to first batch: {0:.2f} s'.format(startup_time)) + print('Estimated warmup time: {0:.2f} s'.format(warmup_time)) # Plot simulation - plot_simulation(step_times, step_downloads) \ No newline at end of file + plot_simulation(step_times, step_downloads) diff --git a/simulation/interfaces/simulation_ui.py b/simulation/interfaces/simulation_ui.py deleted file mode 100644 index 915e2b02b..000000000 --- a/simulation/interfaces/simulation_ui.py +++ /dev/null @@ -1,339 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Simulator web UI using streamlit.""" - -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -import streamlit as st -import numpy as np -import pandas as pd -from io import StringIO -from core.main import simulate -from core.simulation_dataset import SimulationDataset -from core.utils import get_simulation_stats, get_total_batches -from core.sim_time import Time -from core.yaml_processing import ingest_yaml, create_simulation_dataset -from core.create_index import create_stream_index -from core.shuffle_quality import analyze_shuffle_quality -from interfaces.interface_utils import get_train_dataset_params -from interfaces.widgets import param_inputs, get_line_chart, get_shuffle_quality_chart,\ - display_simulation_stats, display_shuffle_quality_graph -import yaml -from typing import Optional, Union -from concurrent.futures import ProcessPoolExecutor -from streaming.base.util import bytes_to_int, number_abbrev_to_int -from functools import partial, reduce - - -# set up page -st.set_page_config(layout="wide") -col1, space, col2 = st.columns((10, 1, 6)) -col2.title("Streaming Simulator") -col2.write("Enter run parameters in the left panel.") -col2.text("") -progress_bar = col1.progress(0) -status_text = col1.empty() -col1.text("") -throughput_plot = col2.empty() -network_plot = col2.empty() -sim_stats = col2.empty() -col2.text("") -shuffle_quality_plot = col2.empty() -throughput_window = 10 -shuffle_quality_algos = ["naive", "py1b", "py1br", "py1e", "py1s", "py2s", "none"] - -# Identity function for executor.map since it doesn't like lambdas. -def return_input(x): - return x - -def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_sample: float, - node_internet_bandwidth: Union[float,str], max_duration: Time): - total_batches = get_total_batches(dataset=dataset, max_duration=max_duration) - node_internet_bandwidth = bytes_to_int(node_internet_bandwidth) - cache_limit = dataset.get_cache_limit() - gen_sim = simulate(dataset, time_per_sample, node_internet_bandwidth, - generator=True, max_duration=max_duration) - gen_step_times = [] - gen_step_downloads = [] - rolling_throughput_data = [] - immediate_throughput_data = [] - network_data = [] - steps = [] - time_to_first_batch = 0 - futures = [] - shuffle_quality_graphed = False - # Define partial function to pass to executor map for simulation. - with ProcessPoolExecutor(max_workers=8) as executor: - # Submit shuffle quality job to executor. - if shuffle_quality: - col1.write("Starting shuffle quality analysis...") - input_params = st.session_state["input_params"] - # Use multiprocessing to get the shuffle quality results. - canonical_nodes = input_params["canonical_nodes"] - physical_nodes = input_params["physical_nodes"] - devices = input_params["devices"] - workers = input_params["workers"] - device_batch_size = input_params["device_batch_size"] - shuffle_block_size = number_abbrev_to_int(input_params["shuffle_block_size"]) - samples_per_shard = dataset.get_avg_samples_per_shard() - epoch_size = dataset.get_epoch_size() - if epoch_size > 100000000: - st.warning('Epoch size is over 100 million samples. Shuffle quality analysis \ - will be conducted only on the first 100 million samples.', icon="⚠️") - seed = input_params["seed"] - # Submit all shuffle quality analysis jobs to executor. - futures = [executor.submit(analyze_shuffle_quality, algo, canonical_nodes, - physical_nodes, devices, workers, device_batch_size, - shuffle_block_size, samples_per_shard, epoch_size, seed) - for algo in shuffle_quality_algos] - - # Simulate only on the main worker, otherwise it's super slow. - for output in gen_sim: - # If output is a length 2, it is the time to first batch and min cache limit. - # Otherwise it is the step, step time, and shard download from the simulation. - if len(output) == 2: - step = total_batches - 1 - time_to_first_batch, min_cache_limit = output - else: - # gen_step_times.append(step_time) - step, step_time, shard_download = output - gen_step_times.append(step_time) - gen_step_downloads.append(shard_download) - # plot throughput once we have enough samples for the window - rolling_throughput = 0 - if step >= throughput_window - 1: - step_time_window = np.array(gen_step_times[-throughput_window:]) - rolling_throughput = 1/np.mean((step_time_window)) - rolling_throughput_data.append(rolling_throughput) - immediate_throughput_data.append(1/step_time) - # plot network usage - cumulative_shard_download = np.sum(np.array(gen_step_downloads)) - network_data.append(cumulative_shard_download) - steps.append(step+1) - - # update plots and percentages at regular intervals - plot_interval = (total_batches) // 15 - if step == 1 or step % plot_interval == 0 or step == total_batches - 1: - rolling_throughput_df = pd.DataFrame({"step": steps, "measurement": [" rolling avg"]*len(rolling_throughput_data), "throughput (batches/s)": rolling_throughput_data}) - throughput_df = rolling_throughput_df - network_df = pd.DataFrame({"step": steps, "cumulative network usage (bytes)": network_data}) - throughput_plot.altair_chart(get_line_chart(throughput_df, throughput_window, True), use_container_width=True) - network_plot.altair_chart(get_line_chart(network_df, throughput_window, False), use_container_width=True) - # update progress bar and text - percentage = int(100*(step+1) / (total_batches)) - status_text.text("%i%% Complete" % percentage) - progress_bar.progress(percentage) - - # If applicable, check if the shuffle quality tasks are finished, and graph. - if shuffle_quality and all([f.done() for f in futures]) \ - and not shuffle_quality_graphed: - display_shuffle_quality_graph(futures, shuffle_quality_plot) - shuffle_quality_graphed = True - - gen_step_times = np.array(gen_step_times) - gen_step_downloads = np.array(gen_step_downloads) - device_batch_size = dataset.get_batch_size() - display_simulation_stats(sim_stats, total_batches, gen_step_times, time_per_sample, - device_batch_size, time_to_first_batch, min_cache_limit, - cache_limit) - - # If shuffle quality still hasn't been graphed yet, we get the result and graph it. - if shuffle_quality and not shuffle_quality_graphed: - display_shuffle_quality_graph(futures, shuffle_quality_plot) - shuffle_quality_graphed = True - -# Function used to prevent clicking shuffle quality from reloading the whole page. -def clicked_shuffle_quality(): - st.session_state["clicked_shuffle_quality"] = True - -def get_input_params_initial(physical_nodes, devices, workers, global_batch_size, train_dataset, - max_duration, time_per_sample, node_internet_bandwidth): - try: - st.session_state["creating_dataset"] = True - dataset = create_simulation_dataset(physical_nodes, devices, workers, - global_batch_size, train_dataset) - st.session_state["orig_dataset"] = dataset - input_params = {} - # dataset input_params - input_params["streams"] = dataset.get_stream_info() - # training input_params - input_params["max_duration"] = max_duration - input_params["epoch_size"] = dataset.get_epoch_size() - input_params["device_batch_size"] = dataset.get_batch_size() - # hardware and network input_params - input_params["physical_nodes"] = physical_nodes - input_params["devices"] = devices - input_params["time_per_sample"] = time_per_sample - input_params["node_network_bandwidth"] = node_internet_bandwidth - # streaming input_params - input_params["workers"] = workers - input_params["canonical_nodes"] = dataset.get_num_canonical_nodes() - input_params["predownload"] = dataset.get_predownload() - input_params["shuffle"] = dataset.get_shuffle() - input_params["shuffle_algo"] = dataset.get_shuffle_algo() - input_params["shuffle_block_size"] = dataset.get_shuffle_block_size() - input_params["seed"] = dataset.get_shuffle_seed() - input_params["cache_limit"] = dataset.get_cache_limit() - input_params["sampling_method"] = dataset.get_sampling_method() - input_params["sampling_granularity"] = dataset.get_sampling_granularity() - input_params["batching_method"] = dataset.get_batching_method() - # Save input_params and originally created dataset to session state. - st.session_state["input_params"] = input_params - except FileNotFoundError: - st.error('Please wait until the dataset is loaded before changing toggle values too \ - quickly. Doing so can cause issues with creating multiple datasets, since \ - Streamlit reloads widgets every single time a toggle value changes.', icon="🚨") - -# Define parameter input area. - -# Check if the user wants to submit a yaml file. -use_yaml = col1.toggle(":sparkles: **Use `yaml`** :sparkles:", value=True) - -if use_yaml: - uploaded_yaml = col1.file_uploader("Upload a yaml file", type=["yaml"]) - if uploaded_yaml is not None: - string_yaml = StringIO(uploaded_yaml.getvalue().decode("utf-8")).read() - dict_yaml = yaml.safe_load(string_yaml) - total_devices, workers, max_duration, global_batch_size, train_dataset = \ - ingest_yaml(yaml_dict=dict_yaml) - physical_nodes = None - time_per_sample = None - node_internet_bandwidth = None - # Check which parameters we still need to ask for. - col1.write("The parameters below were not found in your yaml file. Enter them here:") - if physical_nodes is None: - physical_nodes = col1.number_input('number of physical nodes', step=1, value=1, help="number of physical nodes for this run. a node typically consists of 8 devices (GPUs).") - # Using physical_nodes, calculate number of devices per node. - if total_devices is None: - devices = col1.number_input('devices per node', step=1, value=8, help="number of devices (GPUs) per node for this run. there are typically 8 devices per node.") - else: - if total_devices % physical_nodes != 0: - raise ValueError("The number of devices must be divisible by the number of nodes.") - devices = total_devices // physical_nodes - if time_per_sample is None: - time_per_sample = col1.number_input('process time per sample (s)', step = 0.0005, value=0.0175, format="%.4f", help="time for one device to process one sample from your dataset.") - if node_internet_bandwidth is None: - node_internet_bandwidth = col1.text_input('network bandwidth per node (bytes/s)', - value="1GB", - help="network bandwidth available to each \ - node. in practice, network bandwidth is \ - variable and is affected by many factors, \ - including cluster demand.") - - submitted = col1.button("Simulate Run", use_container_width=True) - shuffle_quality = col1.toggle("Analyze Shuffle Quality", value=False, - help="Analyze shuffle qualities for this run for different \ - shuffle algos using an entropy-based metric. ⚠️ **Results \ - are *noisy estimates* and may not reflect the true \ - shuffle quality.**") - modify_params = col1.toggle("Modify Parameters", value=False) - - # Display components and take actions based on the values of the above three buttons. - if modify_params: - # Create dataset and input_params if it doesn't already exist. - if "input_params" not in st.session_state: - col1.write("Preparing dataset for modification...") - get_input_params_initial(physical_nodes, devices, workers, global_batch_size, - train_dataset, max_duration, time_per_sample, - node_internet_bandwidth) - # We have input_params in the session state. Use it to populate the form. - defaults = st.session_state["input_params"] - # Define parameter input area with default values. - input_params = {} - param_inputs(col1, input_params, defaults=defaults) - # input_params has been repopulated with new values. Save to session state. - st.session_state["input_params"] = input_params - - if submitted: - # Create dataset if it is not yet present. - if "input_params" not in st.session_state: - col1.write("Preparing dataset for this run...") - get_input_params_initial(physical_nodes, devices, workers, global_batch_size, - train_dataset, max_duration, time_per_sample, - node_internet_bandwidth) - # If modify_params is false, we submit the jobs using the original dataset from yaml. - if not modify_params: - col1.write("Starting Simulation...") - dataset = st.session_state["orig_dataset"] - # shuffle_quality is passed through to the job submission function. - submit_jobs(shuffle_quality, dataset, time_per_sample, - node_internet_bandwidth, max_duration) - else: - # If modify_params is true, we retrieve the most recent input params from session - # state, create a new dataset, and submit the jobs. - col1.write("Preparing dataset with modifications...") - # Get parameters for new SimulationDataset from input_params and train_dataset. - input_params = st.session_state["input_params"] - train_dataset = get_train_dataset_params(input_params, old_params=train_dataset) - # Get the rest of the needed params from the new inputs - physical_nodes = input_params["physical_nodes"] - devices = input_params["devices"] - global_batch_size = input_params["device_batch_size"] * devices * physical_nodes - workers = input_params["workers"] - max_duration = input_params["max_duration"] - time_per_sample = input_params["time_per_sample"] - node_internet_bandwidth = input_params["node_network_bandwidth"] - # Make sure node_internet_bandwidth is an int. - dataset = create_simulation_dataset(physical_nodes, devices, workers, - global_batch_size, train_dataset) - col1.write("Starting Simulation...") - submit_jobs(shuffle_quality, dataset, time_per_sample, - node_internet_bandwidth, max_duration) -else: - submitted = col1.button("Simulate Run", use_container_width=True) - col1.text("") - shuffle_quality = col1.toggle("Analyze Shuffle Quality", value=False, - help="Analyze shuffle qualities for this run for different \ - shuffle algos using an entropy-based metric. ⚠️ **Results \ - are *noisy estimates* and may not reflect the true \ - shuffle quality.**") - if "input_params" in st.session_state: - st.session_state["input_params"] = {} - input_params = {} - param_inputs(col1, input_params, defaults=input_params) - if submitted: - # Params have been submitted. Create new dataset and proceed with simulation. - col1.write("Preparing dataset for this run...") - # Create index files and Stream object for each stream. - streams = {} - for stream_idx, stream in input_params["streams"].items(): - stream_dict = {} - if "path" in stream: - # Case when user has provided a path to an index.json file. - stream_folder = os.path.dirname(stream["path"]) - if stream["path_type"] == "local": - stream_dict["local"] = stream_folder - else: - stream_dict["remote"] = stream_folder - else: - # Case when user provides estimates for stream characteristics. - index_path = create_stream_index(stream["shards"], stream["samples_per_shard"], stream["avg_raw_shard_size"], stream["avg_zip_shard_size"]) - stream_folder = os.path.dirname(index_path) - stream_dict["local"] = stream_folder - stream_dict["proportion"] = stream["proportion"] - stream_dict["repeat"] = stream["repeat"] - stream_dict["choose"] = stream["choose"] - streams[stream_idx] = stream_dict - input_params["streams"] = streams - # Get parameters for new SimulationDataset from input_params and train_dataset. - train_dataset = get_train_dataset_params(input_params, create_indices=True) - # Get the rest of the needed params from the new inputs - physical_nodes = input_params["physical_nodes"] - devices = input_params["devices"] - global_batch_size = input_params["device_batch_size"] * devices * physical_nodes - workers = input_params["workers"] - max_duration = input_params["max_duration"] - time_per_sample = input_params["time_per_sample"] - node_internet_bandwidth = input_params["node_network_bandwidth"] - dataset = create_simulation_dataset(physical_nodes, devices, workers, global_batch_size, train_dataset) - # Make sure input_params is in session state. - st.session_state["input_params"] = input_params - col1.write("Starting Simulation...") - submit_jobs(shuffle_quality, dataset, time_per_sample, - node_internet_bandwidth, max_duration) - - \ No newline at end of file diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index 369a18a35..db9f13858 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -8,341 +8,424 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +from concurrent.futures import Future +from typing import Optional + import altair as alt -from streaming.base.util import bytes_to_int +import pandas as pd +import streamlit as st from core.sim_time import TimeUnit, ensure_time from core.utils import get_simulation_stats from numpy.typing import NDArray -import streamlit as st -import pandas as pd -from typing import Optional +from streamlit.delta_generator import DeltaGenerator +import humanize +from streaming.base.util import bytes_to_int -def get_line_chart(data, throughput_window, throughput=True): - hover = alt.selection_point( - fields=["step"], - nearest=True, - on="mouseover", - empty=False, - ) - lines = ( - alt.Chart(data, title="Throughput (" + str(throughput_window) + "-step rolling average)") - .mark_line() - .encode( - x="step", - y="throughput (batches/s)", - ) - ) if throughput else ( - alt.Chart(data, title="Cumulative Network Usage, all nodes") - .mark_line() - .encode( - x="step", - y="cumulative network usage (bytes)" - ) - ) +def get_line_chart(data: pd.DataFrame, + throughput_window: int, + throughput: bool = True) -> alt.Chart: + """Get interactive line charts for throughput or cumulative downloads. - # Draw points on the line, and highlight based on selection - points = lines.transform_filter(hover).mark_circle(size=65) - - # Draw a rule at the location of the selection - tooltips = ( - alt.Chart(data) - .mark_rule() - .encode( - x="step", - y="throughput (batches/s)" if throughput else "cumulative network usage (bytes)", - opacity=alt.condition(hover, alt.value(0.3), alt.value(0)), - tooltip=[ - alt.Tooltip("step", title="Step"), - alt.Tooltip("throughput (batches/s)" if throughput else "cumulative network usage (bytes)", title="Throughput" if throughput else "Network Usage"), - ], - ) - .add_params(hover) - ) - return (lines + points + tooltips).interactive() + Args: + data (pd.DataFrame): Dataframe containing throughput or cumulative downloads data. + throughput_window (int): Window size for throughput rolling average. + throughput (bool, optional): Whether to display throughput or cumulative downloads. If + ``True``, returns chart for throughput. Otherwise, returns chart for downloads. + Defaults to ``True``. + + Returns: + alt.Chart: Interactive line chart for throughput or cumulative downloads. + """ + hover = alt.selection_point( + fields=['step'], + nearest=True, + on='mouseover', + empty=False, + ) + + lines = (alt.Chart(data, + title='Throughput (' + str(throughput_window) + + '-step rolling average)').mark_line().encode( + x='step', + y='throughput (batches/s)', + )) if throughput else (alt.Chart( + data, title='Cumulative Network Usage, all nodes').mark_line().encode( + x='step', y='cumulative network usage (bytes)')) + + # Draw points on the line, and highlight based on selection + points = lines.transform_filter(hover).mark_circle(size=65) + + # Draw a rule at the location of the selection + tooltips = (alt.Chart(data).mark_rule().encode( + x='step', + y='throughput (batches/s)' if throughput else 'cumulative network usage (bytes)', + opacity=alt.condition(hover, alt.value(0.3), alt.value(0)), + tooltip=[ + alt.Tooltip('step', title='Step'), + alt.Tooltip( + 'throughput (batches/s)' if throughput else 'cumulative network usage (bytes)', + title='Throughput' if throughput else 'Network Usage'), + ], + ).add_params(hover)) + return (lines + points + tooltips).interactive() -def stream_entry(col, streams, key, add_stream: bool = True, defaults: dict = None): + +def stream_entry(component: DeltaGenerator, + streams: dict, + key: int, + add_stream: bool = True, + defaults: Optional[dict] = None): + """Define stream input area widget. + + Args: + component (DeltaGenerator): Streamlit component to write on. + streams (dict): Dictionary to store different stream entries. + key (int): Key for stream entry. + add_stream (bool, optional): Whether to show the option to add another stream. + Defaults to ``True``. + defaults (dict, optional): Dictionary of default values for stream entries. Defaults to + ``None``. + """ stream_entries = {} - col.write(f"*Stream {key+1}*") - on = col.toggle("use `index.json`", key=str(key)+"toggle") if add_stream else None + component.write(f'*Stream {key+1}*') + on = component.toggle('use `index.json`', key=str(key) + 'toggle') if add_stream else None if on or not add_stream: - path = col.text_input("path to `index.json`", - value="/absolute/path/to/index.json" - if defaults is None else defaults["path"], - help="path to the `index.json` file for this stream. \ + path = component.text_input( + 'path to `index.json`', + value='/absolute/path/to/index.json' if defaults is None else defaults['path'], + help='path to the `index.json` file for this stream. \ the `index.json` file contains information about the shards in \ - your dataset.", key=str(key)+"path", disabled=(not add_stream)) + your dataset.', + key=str(key) + 'path', + disabled=(not add_stream)) if add_stream: - path_type = col.selectbox('path type', ["local", "remote"], key=str(key)+"path_type") - stream_entries["path_type"] = path_type - stream_entries["path"] = path + path_type = component.selectbox('path type', ['local', 'remote'], + key=str(key) + 'path_type') + stream_entries['path_type'] = path_type + stream_entries['path'] = path else: - shards = col.number_input('number of shards', step=1, value=20850, help="number of total \ - shards across your whole dataset.", key=str(key)+"shards") - samples_per_shard = col.number_input('samples per shard', step=1, value=4093, - help="average number of samples contained \ - in each shard.", key=str(key)+"samples") - avg_raw_shard_size = col.text_input('avg raw shard size (bytes)', value="67MB", - help="average raw size, in bytes, \ - of a single shard.", key=str(key)+"rawsize") + shards = component.number_input('number of shards', + step=1, + value=20850, + help='number of \ + total shards across your whole dataset.', + key=str(key) + 'shards') + samples_per_shard = component.number_input('samples per shard', + step=1, + value=4093, + help='average number of samples contained \ + in each shard.', + key=str(key) + 'samples') + avg_raw_shard_size = component.text_input('avg raw shard size (bytes)', + value='67MB', + help='average raw size, in bytes, \ + of a single shard.', + key=str(key) + 'rawsize') avg_raw_shard_size = bytes_to_int(avg_raw_shard_size) - avg_zip_shard_size = col.text_input('avg compressed shard size (bytes)', value="None", - help="average compressed size, in bytes, \ - of a single shard.", key=str(key)+"zipsize") - avg_zip_shard_size = None if avg_zip_shard_size == "None" \ + avg_zip_shard_size = component.text_input('avg compressed shard size (bytes)', + value='None', + help='average compressed size, \ + in bytes, of a single shard.', + key=str(key) + 'zipsize') + avg_zip_shard_size = None if avg_zip_shard_size == 'None' \ else bytes_to_int(avg_zip_shard_size) - stream_entries["shards"] = shards - stream_entries["samples_per_shard"] = samples_per_shard - stream_entries["avg_raw_shard_size"] = avg_raw_shard_size - stream_entries["avg_zip_shard_size"] = avg_zip_shard_size - proportion = col.text_input('proportion', - value="None" if defaults is None else defaults["proportion"], - help="proportion of the full training dataset that this stream \ - represents.", key=str(key)+"proportion", - disabled=(not add_stream)) - proportion = float(proportion) if proportion != "None" else None - repeat = col.text_input('repeat', - value="None" if defaults is None else defaults["repeat"], - help="number of times to repeat the samples in this \ - stream.", key=str(key)+"repeat", - disabled=(not add_stream)) - repeat = float(repeat) if repeat != "None" else None - choose = col.text_input('choose', - value="None" if defaults is None else defaults["choose"], - help="number of samples to choose from this \ - stream.", key=str(key)+"choose", - disabled=(not add_stream)) - choose = int(choose) if choose != "None" else None - stream_entries["proportion"] = proportion - stream_entries["repeat"] = repeat - stream_entries["choose"] = choose - + stream_entries['shards'] = shards + stream_entries['samples_per_shard'] = samples_per_shard + stream_entries['avg_raw_shard_size'] = avg_raw_shard_size + stream_entries['avg_zip_shard_size'] = avg_zip_shard_size + proportion = component.text_input( + 'proportion', + value='None' if defaults is None else defaults['proportion'], + help='proportion of the full training dataset that this stream \ + represents.', + key=str(key) + 'proportion', + disabled=(not add_stream)) + proportion = float(proportion) if proportion != 'None' else None + repeat = component.text_input('repeat', + value='None' if defaults is None else defaults['repeat'], + help='number of times to repeat the samples in this \ + stream.', + key=str(key) + 'repeat', + disabled=(not add_stream)) + repeat = float(repeat) if repeat != 'None' else None + choose = component.text_input('choose', + value='None' if defaults is None else defaults['choose'], + help='number of samples to choose from this \ + stream.', + key=str(key) + 'choose', + disabled=(not add_stream)) + choose = int(choose) if choose != 'None' else None + stream_entries['proportion'] = proportion + stream_entries['repeat'] = repeat + stream_entries['choose'] = choose + streams[key] = stream_entries - if add_stream and col.checkbox(label="add stream", key=str(key)+"checkbox"): - stream_entry(col, streams, key+1) + if add_stream and component.checkbox(label='add stream', key=str(key) + 'checkbox'): + stream_entry(component, streams, key + 1) + -def param_inputs(col, input_params: dict, defaults: dict = {}): - """Define parameter input area.""" - col3, col4, col5 = col.columns(3) +def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = {}): + """Define parameter input area widget. + + Args: + component (DeltaGenerator): Streamlit component to write to. + input_params (dict): Dictionary to store input parameters. + defaults (dict): Dictionary of default values for input params. Defaults to empty dict, {}. + """ + # split the input column component into left, middle, and right sub columns. + colL, colM, colR = component.columns(3) # dataset streams = {} - col3.write("**Dataset Parameters**") - if "streams" in defaults: + colL.write('**Dataset Parameters**') + if 'streams' in defaults: key = 0 - for _, stream in defaults["streams"].items(): + for stream in defaults['streams'].values(): # Case is only possible when reading in streams from yaml file. Stream will have path. - stream_entry(col3, streams, key, add_stream=False, defaults=stream) + stream_entry(colL, streams, key, add_stream=False, defaults=stream) key += 1 - streams = defaults["streams"] + streams = defaults['streams'] else: - stream_entry(col3, streams, 0, add_stream=True) - col3.text("") - input_params["streams"] = streams + stream_entry(colL, streams, 0, add_stream=True) + colL.text('') + input_params['streams'] = streams # training - col4.write("**Training Parameters**") - if "max_duration" in defaults: - default_max_duration = defaults["max_duration"] + colM.write('**Training Parameters**') + if 'max_duration' in defaults: + default_max_duration = defaults['max_duration'] default_value = int(default_max_duration.value) default_unit_index = 0 if default_max_duration.unit == TimeUnit.BATCH else 1 - time_value = col4.number_input('training duration', step=1, - value = default_value, - help="training duration value, in specified units.") - time_units = col4.selectbox('units', ["batches", "epochs"], - index = default_unit_index, - help="units of training duration.") + time_value = colM.number_input('training duration', + step=1, + value=default_value, + help='training duration value, in specified units.') + time_units = colM.selectbox('units', ['batches', 'epochs'], + index=default_unit_index, + help='units of training duration.') else: - time_value = col4.number_input('training duration', step=1, + time_value = colM.number_input('training duration', + step=1, value=1000, - help="training duration value, in specified units.") - time_units = col4.selectbox('units', ["batches", "epochs"], - help="units of training duration.") + help='training duration value, in specified units.') + time_units = colM.selectbox('units', ['batches', 'epochs'], + help='units of training duration.') # Get Time object from inputs time_string = str(time_value) - time_string += "ba" if time_units == "batches" else "ep" + time_string += 'ba' if time_units == 'batches' else 'ep' max_duration = ensure_time(time_string, TimeUnit.EPOCH) - epoch_size = col4.text_input('epoch size (samples)', value="", - help="epoch size for this run, in samples.") - epoch_size = None if epoch_size == "" or epoch_size == "None" else int(epoch_size) - device_batch_size = col4.number_input('device batch size', step=1, - value=16 if "device_batch_size" not in defaults - else defaults["device_batch_size"], - help="number of samples per device (GPU) per batch. \ + epoch_size = colM.text_input('epoch size (samples)', + value='', + help='epoch size for this run, in samples.') + epoch_size = None if epoch_size == '' or epoch_size == 'None' else int(epoch_size) + device_batch_size = colM.number_input( + 'device batch size', + step=1, + value=16 if 'device_batch_size' not in defaults else defaults['device_batch_size'], + help='number of samples per device (GPU) per batch. \ the global batch size is `device_batch_size * \ - devices_per_node * physical_nodes`") - col4.text("") - input_params["max_duration"] = max_duration - input_params["epoch_size"] = epoch_size - input_params["device_batch_size"] = device_batch_size + devices_per_node * physical_nodes`') + colM.text('') + input_params['max_duration'] = max_duration + input_params['epoch_size'] = epoch_size + input_params['device_batch_size'] = device_batch_size # hardware and network - col4.write("**Hardware and Network Parameters**") - physical_nodes = col4.number_input('number of physical nodes', step=1, - value=1 if "physical_nodes" not in defaults - else defaults["physical_nodes"], - help="number of physical nodes for this run. \ - a node typically consists of 8 devices (GPUs).") - devices = col4.number_input('devices per node', step=1, - value=8 if "devices" not in defaults else defaults["devices"], - help="number of devices (GPUs) per node for this run. \ - there are typically 8 devices per node.") - time_per_sample = col4.number_input('process time per sample (s)', step = 0.0005, - value=0.0175 if "time_per_sample" not in defaults - else defaults["time_per_sample"], - format="%.4f", help="time for one device to process one \ - sample from your dataset.") - node_network_bandwidth = col4.text_input('network bandwidth per node (bytes/s)', - value="500MB" if "node_network_bandwidth" not in defaults - else defaults["node_network_bandwidth"], - help="network bandwidth available to \ + colM.write('**Hardware and Network Parameters**') + physical_nodes = colM.number_input( + 'number of physical nodes', + step=1, + value=1 if 'physical_nodes' not in defaults else defaults['physical_nodes'], + help='number of physical nodes for this run. \ + a node typically consists of 8 devices (GPUs).') + devices = colM.number_input('devices per node', + step=1, + value=8 if 'devices' not in defaults else defaults['devices'], + help='number of devices (GPUs) per node for this run. \ + there are typically 8 devices per node.') + time_per_sample = colM.number_input( + 'process time per sample (s)', + step=0.0005, + value=0.0175 if 'time_per_sample' not in defaults else defaults['time_per_sample'], + format='%.4f', + help='time for one device to process one \ + sample from your dataset.') + node_network_bandwidth = colM.text_input( + 'network bandwidth per node (bytes/s)', + value='500MB' + if 'node_network_bandwidth' not in defaults else defaults['node_network_bandwidth'], + help='network bandwidth available to \ each node. in practice, network bandwidth is \ variable and is affected by many factors, \ - including cluster demand.") - col4.text("") - input_params["physical_nodes"] = physical_nodes - input_params["devices"] = devices - input_params["time_per_sample"] = time_per_sample - input_params["node_network_bandwidth"] = node_network_bandwidth + including cluster demand.') + colM.text('') + input_params['physical_nodes'] = physical_nodes + input_params['devices'] = devices + input_params['time_per_sample'] = time_per_sample + input_params['node_network_bandwidth'] = node_network_bandwidth # streaming - col5.write("**Streaming Parameters**") - workers = col5.number_input('workers per device', step=1, - value=8 if "workers" not in defaults else defaults["workers"], - help="number of dataloader \workers per device (GPU).") - canonical_nodes = col5.number_input('number of canonical nodes', step=1, - value=2 if "canonical_nodes" not in defaults - else defaults["canonical_nodes"], - help="number of canonical nodes to split your dataset \ + colR.write('**Streaming Parameters**') + workers = colR.number_input('workers per device', + step=1, + value=8 if 'workers' not in defaults else defaults['workers'], + help='number of dataloader workers per device (GPU).') + canonical_nodes = colR.number_input( + 'number of canonical nodes', + step=1, + value=2 if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], + help='number of canonical nodes to split your dataset \ into. a canonical node is a bucket of shards that is \ - assigned to a particular physical node.") - predownload = col5.text_input('predownload per worker (samples)', - value="None" if "predownload" not in defaults - else defaults["predownload"], - help="number of samples ahead each worker should download. \ + assigned to a particular physical node.') + predownload = colR.text_input( + 'predownload per worker (samples)', + value='None' if 'predownload' not in defaults else defaults['predownload'], + help='number of samples ahead each worker should download. \ predownload does not occur before the first batch; \ - rather, it occurs while training is ongoing.") - predownload = None if predownload == "" or predownload == "None" else int(predownload) - shuffle = col5.checkbox(label="shuffle", value=True if "shuffle" not in defaults - else defaults["shuffle"], - help="whether or not to shuffle the samples for this run.") - shuffle_algo="py1e" if defaults is None or "shuffle_algo" not in defaults \ - else defaults["shuffle_algo"] - shuffle_block_size="1M" if defaults is None or "shuffle_block_size" not in defaults \ - else defaults["shuffle_block_size"] - seed=42 if defaults is None or "seed" not in defaults else defaults["seed"] + rather, it occurs while training is ongoing.') + predownload = None if predownload == '' or predownload == 'None' else int(predownload) + shuffle = colR.checkbox(label='shuffle', + value=True if 'shuffle' not in defaults else defaults['shuffle'], + help='whether or not to shuffle the samples for this run.') + shuffle_algo='py1e' if len(defaults) == 0 or 'shuffle_algo' not in defaults \ + else defaults['shuffle_algo'] + shuffle_block_size='1M' if len(defaults) == 0 or 'shuffle_block_size' not in defaults \ + else defaults['shuffle_block_size'] + seed = 42 if len(defaults) == 0 or 'seed' not in defaults else defaults['seed'] if shuffle: - algos = ["py1e", "py1br", "py1b", "py1s", "py2s", "naive"] + algos = ['py1e', 'py1br', 'py1b', 'py1s', 'py2s', 'naive'] default_index = 0 - if "shuffle_algo" in defaults: - default_index = algos.index(defaults["shuffle_algo"]) - shuffle_algo = col5.selectbox('shuffling algorithm', algos, index=default_index, - help="shuffling algorithm to use for this run. your shuffle \ - parameters may affect model training.") - shuffle_block_size = col5.text_input('shuffle block size (samples)', - value="2M" if "shuffle_block_size" not in defaults - else defaults["shuffle_block_size"], - help="shuffle block size for this run. \ - used in the `py1b`, `py1br`, and `py1e` \ - shuffling algorithms, samples in blocks of \ - `shuffle_block_size` are randomly shuffled \ - inside each bucket of shards (aka canonical node).") - seed = col5.number_input('shuffle seed', step=1, - value=42 if "seed" not in defaults else defaults["seed"], - help="random seed for shuffling.") - cache_limit = col5.text_input('cache limit (bytes)', - value="None" if "cache_limit" not in defaults - else defaults["cache_limit"], - help="cache limit per node for this run. \ - setting cache limit too low will impact throughput.") - cache_limit = None if cache_limit=="" or cache_limit=="None" else bytes_to_int(cache_limit) - sampling_methods = ["balanced", "fixed"] - sampling_method = col5.selectbox('sampling method', sampling_methods, - index=0 if "sampling_method" not in defaults - else sampling_methods.index(defaults["sampling_method"]), - help="sampling method for this run. controls how samples are\ + if 'shuffle_algo' in defaults: + default_index = algos.index(defaults['shuffle_algo']) + shuffle_algo = colR.selectbox('shuffling algorithm', + algos, + index=default_index, + help='shuffling algorithm to use for this run. your shuffle \ + parameters may affect model training.') + shuffle_block_size = colR.text_input( + 'shuffle block size (samples)', + value='200k' if 'shuffle_block_size' not in defaults else defaults['shuffle_block_size'], + help='shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` \ + shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly \ + shuffled inside each bucket of shards (aka canonical node).' + ) + seed = colR.number_input('shuffle seed', + step=1, + value=42 if 'seed' not in defaults else defaults['seed'], + help='random seed for shuffling.') + cache_limit = colR.text_input( + 'cache limit (bytes)', + value='None' if 'cache_limit' not in defaults else defaults['cache_limit'], + help='cache limit per node for this run. \ + setting cache limit too low will impact throughput.') + cache_limit = None if cache_limit == '' or cache_limit == 'None' else bytes_to_int(cache_limit) + sampling_methods = ['balanced', 'fixed'] + sampling_method = colR.selectbox('sampling method', + sampling_methods, + index=0 if 'sampling_method' not in defaults else + sampling_methods.index(defaults['sampling_method']), + help="sampling method for this run. controls how samples are\ chosen each epoch. can be either 'balanced' or 'fixed'.") - sampling_granularity = col5.number_input('sampling granularity', step=1, - value=1 if "sampling_granularity" not in defaults - else defaults["sampling_granularity"], - help="sampling granularity for this run. controls how\ + sampling_granularity = colR.number_input( + 'sampling granularity', + step=1, + value=1 if 'sampling_granularity' not in defaults else defaults['sampling_granularity'], + help='sampling granularity for this run. controls how\ samples are balanced across shards. higher values will\ - cause more samples to be drawn from each shard at a time.") - batching_methods = ["random", "per_stream", "stratified"] - batching_method = col5.selectbox('batching method', batching_methods, - index=0 if "batching_method" not in defaults - else batching_methods.index(defaults["batching_method"]), - help="batching method for this run. controls how batches\ - are constructed.") - col5.text("") - input_params["workers"] = workers - input_params["canonical_nodes"] = canonical_nodes - input_params["predownload"] = predownload - input_params["cache_limit"] = cache_limit - input_params["shuffle"] = shuffle - input_params["shuffle_algo"] = shuffle_algo - input_params["shuffle_block_size"] = shuffle_block_size - input_params["seed"] = seed - input_params["sampling_method"] = sampling_method - input_params["sampling_granularity"] = sampling_granularity - input_params["batching_method"] = batching_method - -def display_simulation_stats(component, total_batches: int, step_times: NDArray, - time_per_sample: float, device_batch_size: int, - time_to_first_batch: float, min_cache_limit: int, - cache_limit: Optional[int]): + cause more samples to be drawn from each shard at a time.') + batching_methods = ['random', 'per_stream', 'stratified'] + batching_method = colR.selectbox('batching method', + batching_methods, + index=0 if 'batching_method' not in defaults else + batching_methods.index(defaults['batching_method']), + help='batching method for this run. controls how batches\ + are constructed.') + colR.text('') + input_params['workers'] = workers + input_params['canonical_nodes'] = canonical_nodes + input_params['predownload'] = predownload + input_params['cache_limit'] = cache_limit + input_params['shuffle'] = shuffle + input_params['shuffle_algo'] = shuffle_algo + input_params['shuffle_block_size'] = shuffle_block_size + input_params['seed'] = seed + input_params['sampling_method'] = sampling_method + input_params['sampling_granularity'] = sampling_granularity + input_params['batching_method'] = batching_method + + +def display_simulation_stats(component: DeltaGenerator, total_batches: int, step_times: NDArray, + time_per_sample: float, device_batch_size: int, startup_time: float, + min_cache_limit: int, cache_limit: Optional[int]): + """Display simulation statistics and warnings. + + Args: + component (DeltaGenerator): Streamlit component to write on. + total_batches (int): Total number of batches in the simulation. + step_times (NDArray): Array of step times from the simulation. + time_per_sample (float): Time taken for one device to process one sample, in seconds. + device_batch_size (int): Device batch size. + startup_time (float): startup time from the simulation. + min_cache_limit (int): Minimum cache limit needed to prevent shard redownloads. + cache_limit (Optional[int]): Cache limit provided. + """ all_throughput_drops, warmup_time, warmup_step, post_warmup_throughput_drops = \ get_simulation_stats(step_times, time_per_sample, device_batch_size) with component.container(): - st.write(f"Minimum cache limit needed: **{min_cache_limit:,} bytes**") + st.write(f'Minimum cache limit needed: **{humanize.naturalsize(min_cache_limit)}**') if cache_limit is not None and cache_limit < min_cache_limit: # Cache limit is too low, and will cause shard redownloads / throughput drops. st.warning('The provided cache limit is lower than the minimum cache limit needed to \ prevent shard re-downloads. This can cause throughput issues.', - icon="⚠️") + icon='⚠️') if warmup_step == total_batches: - # display error if the warmup phase is the whole run, + # display error if the warmup phase is the whole run, # meaning that we never hit peak throughput. st.error('This configuration is severely bottlenecked by downloading. \ - The run will not be performant.', icon="🚨") + The run will not be performant.', + icon='🚨') elif post_warmup_throughput_drops: # display warning if post-warmup throughput drops are more than 10% of the run. st.warning('This configuration experiences some downloading-related slowdowns \ - even after warmup.', icon="⚠️") - st.write("**{0} steps**, or **{1:.1f}%** of all steps, waited for \ - shard downloads.".format(all_throughput_drops, - 100*all_throughput_drops/(total_batches))) + even after warmup.', + icon='⚠️') + st.write('**{0} steps**, or **{1:.1f}%** of all steps, waited for \ + shard downloads.'.format(all_throughput_drops, + 100 * all_throughput_drops / (total_batches))) if warmup_step != total_batches: # only display post-warmup throughput drop info if we actually ended the warmup period # (i.e. we hit peak throughput at some point) - st.write("There were **{} steps** that waited for shard downloads after the warmup \ - period.".format(post_warmup_throughput_drops)) - st.write("Estimated time to first batch: **{0:.2f} s**".format(time_to_first_batch)) - st.write("Estimated warmup time: **{0:.2f} s**".format(warmup_time)) - -def get_shuffle_quality_chart(data): - bars = ( - alt.Chart(data, title="Shuffle Quality") - .mark_bar() - .encode( - x="algo", - y="quality", - tooltip="quality" - ) - .properties( - width=550, - ) - ) + st.write('There were **{} steps** that waited for shard downloads after the warmup \ + period.'.format(post_warmup_throughput_drops)) + st.write('Estimated time to first batch: **{0:.2f} s**'.format(startup_time)) + st.write('Estimated warmup time: **{0:.2f} s**'.format(warmup_time)) + +def get_shuffle_quality_chart(data: pd.DataFrame) -> alt.Chart: + """Get interactive bar chart for shuffle quality. + + Args: + data (pd.DataFrame): Dataframe containing shuffle quality data. + + Returns: + alt.Chart: Interactive bar chart for shuffle quality. + """ + bars = (alt.Chart(data, title='Shuffle Quality').mark_bar().encode( + x='algo', y='quality', tooltip='quality').properties(width=550,)) return bars.interactive() -def display_shuffle_quality_graph(futures, component): +def display_shuffle_quality_graph(futures: list[Future], component: DeltaGenerator): + """Display shuffle quality graph. + + Args: + futures (list[Future]): List of futures for shuffle quality results. + component (DeltaGenerator): Streamlit component to write on. + """ # Retrieve shuffle quality result since it is available shuffle_algos_qualities = list(zip(*[f.result() for f in futures])) shuffle_algos = list(shuffle_algos_qualities[0]) shuffle_qualities = list(shuffle_algos_qualities[1]) - shuffle_quality_df = pd.DataFrame({"algo": shuffle_algos, - "quality": shuffle_qualities}) - component.altair_chart(get_shuffle_quality_chart(shuffle_quality_df), - use_container_width=True) \ No newline at end of file + shuffle_quality_df = pd.DataFrame({'algo': shuffle_algos, 'quality': shuffle_qualities}) + component.altair_chart(get_shuffle_quality_chart(shuffle_quality_df), use_container_width=True) diff --git a/simulation/testing/simulation_testing.py b/simulation/testing/simulation_testing.py deleted file mode 100644 index b324d55ec..000000000 --- a/simulation/testing/simulation_testing.py +++ /dev/null @@ -1,169 +0,0 @@ -# Copyright 2023 MosaicML Streaming authors -# SPDX-License-Identifier: Apache-2.0 - -"""Test simulation results against run results from a wandb project.""" - -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -import wandb -import matplotlib.pyplot as plt -import numpy as np -from core.create_index import create_stream_index -from streaming.base import Stream -from core.simulation_dataset import SimulationDataset -from core.sim_time import ensure_time, TimeUnit -from core.main import simulate -import pandas as pd -import os - -api = wandb.Api() - -project_id = "mosaic-ml/streaming-shuffling-algo" -project_runs = api.runs(path=project_id, per_page=300) -project_runs_list = [run.id for run in project_runs] -skip = 0 - -# Enter the dataset parameters here. -# These are the params for C4 dataset, gpt-neox tokenized. -shards = 20850 -samples_per_shard = 4093 -avg_raw_shard_size = 67092639 -avg_zip_shard_size = 16000000 -time_per_sample = 0.0175 -node_network_bandwidth = 1e9 -throughput_window = 10 - -def get_similarity_percentage(real, sim): - real_copy = real.reshape(1, -1) - sim_copy = sim.reshape(1, -1) - merged = np.concatenate((real_copy, sim_copy), axis=0) - similarities = np.abs(real-sim)/np.max(merged, axis=0) - nanmean = np.nanmean(similarities) - return 1 - nanmean - -for run_id in project_runs_list[skip:]: - - run = api.run(f"{project_id}/{run_id}") - - print(run.name) - - summary = run.summary - config = run.config - - if '_step' not in summary: - print("skipping unsuccessful run") - continue - - # get parameters from run config and summary - max_duration_value = summary['_step'] - max_duration = ensure_time(str(max_duration_value) + "ba", TimeUnit.EPOCH) - devices = int(config["num_gpus_per_node"]) - physical_nodes = int(config['n_gpus']/devices) - # device_batch_size set for each run - device_batch_size = int(config['global_train_batch_size']/(physical_nodes*devices)) - canonical_nodes = int(config['num_canonical_nodes']) - workers = int(config["train_loader"]["num_workers"]) - predownload = int(config["train_loader"]["dataset"]["predownload"]) - cache_limit = None - if "cache_limit" in config["train_loader"]["dataset"]: - cache_limit = config["train_loader"]["dataset"]["cache_limit"] - shuffle_algo = None - if "shuffle_algo" in config["train_loader"]["dataset"]: - shuffle_algo = config["train_loader"]["dataset"]["shuffle_algo"] - shuffle_block_size = config["train_loader"]["dataset"]["shuffle_block_size"] - seed = config['seed'] - - # get step timestamps, real throughput, and network use from the run - step_timestamps = run.history(samples=max_duration_value, keys=["_timestamp"], pandas=True) - real_batch_throughput = run.history(samples=max_duration_value-throughput_window, keys=["throughput/batches_per_sec"], pandas=True) - - real_network_use = run.history(stream="system", pandas=True)[["_timestamp", "system.network.recv"]] - - # merge real_network_use with step_timestamps - merged_network_use = pd.merge_asof(real_network_use, step_timestamps, on="_timestamp", direction="nearest") - - # simulate throughput and network use given the inputs - stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, - avg_zip_shard_size) - stream_folder = os.path.dirname(stream_indexpath) - stream = Stream(local=stream_folder) - - dataset = SimulationDataset( - nodes=physical_nodes, - devices=devices, - workers=workers, - streams=[stream], - predownload=predownload, - cache_limit=cache_limit, - num_canonical_nodes=canonical_nodes, - batch_size=device_batch_size, - shuffle=True, - shuffle_algo=shuffle_algo, - shuffle_seed=seed, - shuffle_block_size=shuffle_block_size - ) - - results = simulate(dataset=dataset, - time_per_sample=time_per_sample, - node_network_bandwidth=node_network_bandwidth, - max_duration=max_duration) - - step_times, step_downloads, startup_time, min_cache_limit = next(results) - - immediate_batch_throughput = 1 / step_times - - shard_downloads_cumulative = np.cumsum(step_downloads) - shard_downloads_steps = np.arange(step_downloads.shape[0]) - sim_downloads = pd.DataFrame({"_step": shard_downloads_steps, - "sim_downloads": shard_downloads_cumulative}) - # merge simulated downloads with real downloads dataframe - merged_network_use = pd.merge_asof(merged_network_use, sim_downloads, - on="_step", direction="nearest") - - step_times_rolling_avg = np.convolve(step_times, - np.ones(throughput_window) / throughput_window, - mode='valid')[:-1] - batch_throughput_rolling_avg = 1 / step_times_rolling_avg - sim_throughput = pd.DataFrame({"_step": throughput_window + \ - np.arange(batch_throughput_rolling_avg.shape[0]), - "sim_throughput": batch_throughput_rolling_avg}) - merged_throughput = pd.merge_asof(real_batch_throughput, sim_throughput, - on="_step", direction="nearest") - - # get similarity scores - throughput_similarity = get_similarity_percentage( - merged_throughput["throughput/batches_per_sec"].to_numpy(), - merged_throughput["sim_throughput"].to_numpy()) - network_similarity = get_similarity_percentage( - physical_nodes*(merged_network_use["system.network.recv"].to_numpy()), - (merged_network_use["sim_downloads"].to_numpy())) - - # print params and results to easily paste to spreadsheet - print(run.name, seed, canonical_nodes, physical_nodes, - predownload, shuffle_algo,shuffle_block_size, - cache_limit, max_duration_value, throughput_similarity, network_similarity) - - fig, (ax1, ax2) = plt.subplots(2, 1) - - ax1.set_title("throughput - score: " + str(throughput_similarity)) - ax1.plot(merged_throughput["_step"], merged_throughput["throughput/batches_per_sec"], - color="red", label="real") - ax1.plot(merged_throughput["_step"], merged_throughput["sim_throughput"], - color="blue", label="sim") - ax1.legend() - - ax2.set_title("network use - score: " + str(network_similarity)) - # wandb only logs network use for node 0. multiply by number of nodes to get total network use - ax2.plot(merged_network_use["_timestamp"], - physical_nodes*merged_network_use["system.network.recv"], color="red", label="real") - # simulation assumes all shards are downloaded uncompressed (overestimates). - ax2.plot(merged_network_use["_timestamp"], - merged_network_use["sim_downloads"], color="blue", label="sim") - ax2.legend() - - fig.set_figheight(8) - - plt.show() \ No newline at end of file diff --git a/simulation/testing/wandb_testing.py b/simulation/testing/wandb_testing.py new file mode 100644 index 000000000..e3a619d87 --- /dev/null +++ b/simulation/testing/wandb_testing.py @@ -0,0 +1,207 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Test simulation results against run results from a wandb project.""" + +import os.path +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +import os + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import wandb +from core.create_index import create_stream_index +from core.main import simulate +from core.sim_time import TimeUnit, ensure_time +from core.simulation_dataset import SimulationDataset +from numpy.typing import NDArray + +from streaming.base import Stream + +api = wandb.Api() + +project_id = 'mosaic-ml/streaming-shuffling-algo' +project_runs = api.runs(path=project_id, per_page=300) +project_runs_list = [run.id for run in project_runs] +skip = 0 + +# Enter the dataset parameters here. +# These are the params for C4 dataset, gpt-neox tokenized. +shards = 20850 +samples_per_shard = 4093 +avg_raw_shard_size = 67092639 +avg_zip_shard_size = 16000000 +time_per_sample = 0.0175 +node_network_bandwidth = 1e9 +throughput_window = 10 + + +def get_similarity_percentage(real: NDArray, sim: NDArray) -> float: + """Get similarity score between real and simulated data. + + Args: + real (NDArray): The real data. + sim (NDArray): The simulated data. + + Returns: + float: The similarity score, between 0 and 1. + """ + real_copy = real.reshape(1, -1) + sim_copy = sim.reshape(1, -1) + merged = np.concatenate((real_copy, sim_copy), axis=0) + similarities = np.abs(real - sim) / np.max(merged, axis=0) + nanmean = np.nanmean(similarities) + return float(1 - nanmean) + + +for run_id in project_runs_list[skip:]: + + run = api.run(f'{project_id}/{run_id}') + + print(run.name) + + summary = run.summary + config = run.config + + if '_step' not in summary: + print('skipping unsuccessful run') + continue + + # get parameters from run config and summary + max_duration_value = summary['_step'] + max_duration = ensure_time(str(max_duration_value) + 'ba', TimeUnit.EPOCH) + devices = int(config['num_gpus_per_node']) + physical_nodes = int(config['n_gpus'] / devices) + # device_batch_size set for each run + device_batch_size = int(config['global_train_batch_size'] / (physical_nodes * devices)) + canonical_nodes = int(config['num_canonical_nodes']) + workers = int(config['train_loader']['num_workers']) + predownload = int(config['train_loader']['dataset']['predownload']) + cache_limit = None + if 'cache_limit' in config['train_loader']['dataset']: + cache_limit = config['train_loader']['dataset']['cache_limit'] + shuffle = True + if 'shuffle' in config['train_loader']['dataset']: + shuffle = config['train_loader']['dataset']['shuffle'] + shuffle_algo = 'py1e' + if 'shuffle_algo' in config['train_loader']['dataset']: + shuffle_algo = str(config['train_loader']['dataset']['shuffle_algo']) + shuffle_block_size = config['train_loader']['dataset']['shuffle_block_size'] + seed = config['seed'] + + # get step timestamps, real throughput, and network use from the run + step_timestamps = run.history(samples=max_duration_value, keys=['_timestamp'], pandas=True) + real_batch_throughput = run.history(samples=max_duration_value - throughput_window, + keys=['throughput/batches_per_sec'], + pandas=True) + + real_network_use = run.history(stream='system', + pandas=True)[['_timestamp', 'system.network.recv']] + + # merge real_network_use with step_timestamps + merged_network_use = pd.merge_asof(real_network_use, + step_timestamps, + on='_timestamp', + direction='nearest') + + # simulate throughput and network use given the inputs + stream_indexpath = create_stream_index(shards, samples_per_shard, avg_raw_shard_size, + avg_zip_shard_size) + stream_folder = os.path.dirname(stream_indexpath) + stream = Stream(local=stream_folder) + + dataset = SimulationDataset(nodes=physical_nodes, + devices=devices, + workers=workers, + streams=[stream], + predownload=predownload, + cache_limit=cache_limit, + num_canonical_nodes=canonical_nodes, + batch_size=device_batch_size, + shuffle=shuffle, + shuffle_algo=shuffle_algo, + shuffle_seed=seed, + shuffle_block_size=shuffle_block_size) + + node_network_bandwidth = int(node_network_bandwidth) + results = next( + simulate(dataset=dataset, + time_per_sample=time_per_sample, + node_network_bandwidth=node_network_bandwidth, + max_duration=max_duration)) + + assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' + step_times, step_downloads, startup_time, min_cache_limit = results + + immediate_batch_throughput = 1 / step_times + + shard_downloads_cumulative = np.cumsum(step_downloads) + shard_downloads_steps = np.arange(step_downloads.shape[0]) + sim_downloads = pd.DataFrame({ + '_step': shard_downloads_steps, + 'sim_downloads': shard_downloads_cumulative + }) + # merge simulated downloads with real downloads dataframe + merged_network_use = pd.merge_asof(merged_network_use, + sim_downloads, + on='_step', + direction='nearest') + + step_times_rolling_avg = np.convolve(step_times, + np.ones(throughput_window) / throughput_window, + mode='valid')[:-1] + batch_throughput_rolling_avg = 1 / step_times_rolling_avg + sim_throughput = pd.DataFrame({'_step': throughput_window + \ + np.arange(batch_throughput_rolling_avg.shape[0]), + 'sim_throughput': batch_throughput_rolling_avg}) + merged_throughput = pd.merge_asof(real_batch_throughput, + sim_throughput, + on='_step', + direction='nearest') + + # get similarity scores + throughput_similarity = get_similarity_percentage( + merged_throughput['throughput/batches_per_sec'].to_numpy(), + merged_throughput['sim_throughput'].to_numpy()) + network_similarity = get_similarity_percentage( + physical_nodes * (merged_network_use['system.network.recv'].to_numpy()), + (merged_network_use['sim_downloads'].to_numpy())) + + # print params and results to easily paste to spreadsheet + print(run.name, seed, canonical_nodes, physical_nodes, predownload, shuffle_algo, + shuffle_block_size, cache_limit, max_duration_value, throughput_similarity, + network_similarity) + + fig, (ax1, ax2) = plt.subplots(2, 1) + + ax1.set_title('throughput - score: ' + str(throughput_similarity)) + ax1.plot(merged_throughput['_step'], + merged_throughput['throughput/batches_per_sec'], + color='red', + label='real') + ax1.plot(merged_throughput['_step'], + merged_throughput['sim_throughput'], + color='blue', + label='sim') + ax1.legend() + + ax2.set_title('network use - score: ' + str(network_similarity)) + # wandb only logs network use for node 0. multiply by number of nodes to get total network use + ax2.plot(merged_network_use['_timestamp'], + physical_nodes * merged_network_use['system.network.recv'], + color='red', + label='real') + # simulation assumes all shards are downloaded uncompressed (overestimates). + ax2.plot(merged_network_use['_timestamp'], + merged_network_use['sim_downloads'], + color='blue', + label='sim') + ax2.legend() + + fig.set_figheight(8) + + plt.show() diff --git a/streaming/base/shuffle/__init__.py b/streaming/base/shuffle/__init__.py index 7ce6195d2..f61713762 100644 --- a/streaming/base/shuffle/__init__.py +++ b/streaming/base/shuffle/__init__.py @@ -12,8 +12,6 @@ from streaming.base.shuffle.py1e import get_shuffle_py1e from streaming.base.shuffle.py1s import get_shuffle_py1s from streaming.base.shuffle.py2s import get_shuffle_py2s -from streaming.base.shuffle.py1e import get_shuffle_py1e -from streaming.base.shuffle.py1br import get_shuffle_py1br algos = { 'py1b': get_shuffle_py1b, diff --git a/streaming/base/shuffle/py1br.py b/streaming/base/shuffle/py1br.py index 76d6277d0..eff32210c 100644 --- a/streaming/base/shuffle/py1br.py +++ b/streaming/base/shuffle/py1br.py @@ -90,4 +90,4 @@ def get_shuffle_py1br(shard_sizes: NDArray[np.int64], for block_start, block_stop in block_staggered_ranges: epoch_rng.shuffle(ids[block_start:block_stop]) - return ids \ No newline at end of file + return ids diff --git a/streaming/base/shuffle/py1e.py b/streaming/base/shuffle/py1e.py index 725640559..6127341e1 100644 --- a/streaming/base/shuffle/py1e.py +++ b/streaming/base/shuffle/py1e.py @@ -118,4 +118,4 @@ def get_shuffle_py1e(shard_sizes: NDArray[np.int64], offset += num_cn_samples - return ids \ No newline at end of file + return ids From 590bb4f9b5f51fb760c05db45972825648747b23 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 4 Oct 2023 00:20:55 -0700 Subject: [PATCH 19/31] reversed change to shuffle init.py --- streaming/base/shuffle/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/streaming/base/shuffle/__init__.py b/streaming/base/shuffle/__init__.py index f61713762..e5e529c42 100644 --- a/streaming/base/shuffle/__init__.py +++ b/streaming/base/shuffle/__init__.py @@ -20,8 +20,6 @@ 'py1s': get_shuffle_py1s, 'py2s': get_shuffle_py2s, 'naive': get_shuffle_naive, - 'py1e': get_shuffle_py1e, - 'py1br': get_shuffle_py1br, } From a16edd2257a753d83fd9241931459d9d392653cc Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 4 Oct 2023 00:21:53 -0700 Subject: [PATCH 20/31] linting --- simulation/interfaces/sim_script.py | 6 ++++-- simulation/interfaces/simcli.py | 3 ++- simulation/interfaces/widgets.py | 9 +++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/simulation/interfaces/sim_script.py b/simulation/interfaces/sim_script.py index ba9621c89..7aeda2f0b 100644 --- a/simulation/interfaces/sim_script.py +++ b/simulation/interfaces/sim_script.py @@ -8,13 +8,14 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import humanize from core.create_index import create_stream_index from core.main import simulate from core.sim_time import TimeUnit, ensure_time from core.simulation_dataset import SimulationDataset from core.utils import get_simulation_stats from interfaces.interface_utils import plot_simulation -import humanize + from streaming.base import Stream # Input Parameters @@ -97,7 +98,8 @@ performant.') elif post_warmup_throughput_drops: # display warning if post-warmup throughput drops are more than 10% of the run. - print('⚠️ This configuration experiences some downloading-related slowdowns even after warmup.') + print( + '⚠️ This configuration experiences some downloading-related slowdowns even after warmup.') print('{0} steps, or {1:.1f}% of all steps, waited for shard downloads.'\ .format(all_throughput_drops, 100 * all_throughput_drops / (total_batches))) if warmup_step != total_batches: diff --git a/simulation/interfaces/simcli.py b/simulation/interfaces/simcli.py index 6fb626f67..52cfad864 100644 --- a/simulation/interfaces/simcli.py +++ b/simulation/interfaces/simcli.py @@ -10,11 +10,12 @@ import argparse +import humanize from core.main import simulate from core.utils import get_simulation_stats from core.yaml_processing import create_simulation_dataset, ingest_yaml from interfaces.interface_utils import plot_simulation -import humanize + from streaming.base.util import bytes_to_int if __name__ == '__main__': diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index db9f13858..8a9dfe390 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -12,13 +12,14 @@ from typing import Optional import altair as alt +import humanize import pandas as pd import streamlit as st from core.sim_time import TimeUnit, ensure_time from core.utils import get_simulation_stats from numpy.typing import NDArray from streamlit.delta_generator import DeltaGenerator -import humanize + from streaming.base.util import bytes_to_int @@ -305,11 +306,11 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = parameters may affect model training.') shuffle_block_size = colR.text_input( 'shuffle block size (samples)', - value='200k' if 'shuffle_block_size' not in defaults else defaults['shuffle_block_size'], + value='200k' + if 'shuffle_block_size' not in defaults else defaults['shuffle_block_size'], help='shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` \ shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly \ - shuffled inside each bucket of shards (aka canonical node).' - ) + shuffled inside each bucket of shards (aka canonical node).') seed = colR.number_input('shuffle seed', step=1, value=42 if 'seed' not in defaults else defaults['seed'], From 25c3e50d0c1071400a08e3daf0809dde15fe52c1 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 4 Oct 2023 15:17:46 -0700 Subject: [PATCH 21/31] fixed yaml parsing bug --- simulation/core/yaml_processing.py | 12 ++++++------ simulation/interfaces/widgets.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index 99ecbf1dd..bce22fcf4 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -14,7 +14,6 @@ from core.simulation_dataset import SimulationDataset from omegaconf import DictConfig from omegaconf import OmegaConf as om -from omegaconf import SCMode from streaming.base import Stream @@ -55,7 +54,11 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, global_batch_size = None # Get the training and dataset params if 'parameters' in config: - config = config['parameters'] + config = om.create(config['parameters']) + + om.resolve(config) + + assert isinstance(config, DictConfig), 'config must be a dict.' # get global batch size if 'global_train_batch_size' in config: @@ -107,10 +110,7 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, # convert train_dataset to dict, if it isn't already if isinstance(train_dataset, DictConfig): - train_dataset = om.to_container(train_dataset, - resolve=False, - throw_on_missing=True, - structured_config_mode=SCMode.DICT) + train_dataset = om.to_container(train_dataset) assert isinstance(workers, int), 'workers must be an integer.' assert isinstance(global_batch_size, int), 'global_batch_size must be an integer.' diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index 8a9dfe390..e0e0cd214 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -412,7 +412,7 @@ def get_shuffle_quality_chart(data: pd.DataFrame) -> alt.Chart: Returns: alt.Chart: Interactive bar chart for shuffle quality. """ - bars = (alt.Chart(data, title='Shuffle Quality').mark_bar().encode( + bars = (alt.Chart(data, title='Shuffle Quality (higher is better)').mark_bar().encode( x='algo', y='quality', tooltip='quality').properties(width=550,)) return bars.interactive() From 88b92e848f4811cdbf80b22a1d8d2658c1e490fd Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 10 Oct 2023 16:09:21 -0700 Subject: [PATCH 22/31] addressed comments on setup.py and create_index.py --- setup.py | 10 +++++----- simulation/core/create_index.py | 26 ++++++++++---------------- simulation/core/main.py | 5 ----- simulation/core/node_tracker.py | 5 ----- simulation/core/shard_downloads.py | 5 ----- simulation/core/shuffle_quality.py | 5 ----- simulation/core/simulation_dataset.py | 5 ----- simulation/core/utils.py | 5 ----- simulation/core/yaml_processing.py | 5 ----- simulation/interfaces/sim_script.py | 2 +- 10 files changed, 16 insertions(+), 57 deletions(-) diff --git a/setup.py b/setup.py index 91e0e3781..06fdfea38 100644 --- a/setup.py +++ b/setup.py @@ -94,11 +94,11 @@ ] extra_deps['simulator'] = [ - 'sortedcollections>=2.1.0', - 'streamlit>=1.26.0', - 'altair>=5.1.1', - 'omegaconf>=2.3.0', - 'PyYAML>=6.0', + 'sortedcollections>=2.1.0,<3', + 'streamlit>=1.26.0,<2', + 'altair>=5.1.1,<6', + 'omegaconf>=2.3.0,<3', + 'PyYAML>=6.0,<7', ] extra_deps['spark'] = [ diff --git a/simulation/core/create_index.py b/simulation/core/create_index.py index 1cbdb5c50..a78b5524a 100644 --- a/simulation/core/create_index.py +++ b/simulation/core/create_index.py @@ -3,17 +3,14 @@ """Create a dataset index file from input parameters.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - import json import os import random import string from typing import Optional +from streaming.base.format import get_index_basename + def get_random_foldername(): """Generate random folder name to store the index file in.""" @@ -38,14 +35,14 @@ def create_stream_index(shards: int, samples_per_shard: int, avg_raw_shard_size: index_data = {'version': 2, 'shards': []} shards_list = [] - for shard_id in range(shards): + for _ in range(shards): shard_data = { 'column_encodings': [], 'column_names': [], 'column_sizes': [], 'format': 'mds', 'raw_data': { - 'basename': 'shard.' + str(shard_id) + '.mds', + 'basename': '', 'bytes': avg_raw_shard_size, 'hashes': {} }, @@ -57,12 +54,8 @@ def create_stream_index(shards: int, samples_per_shard: int, avg_raw_shard_size: 'compression': None } if avg_zip_shard_size is not None: - shard_data['zip_data'] = { - 'basename': 'shard.' + str(shard_id) + '.mds.zstd', - 'bytes': avg_zip_shard_size, - 'hashes': {} - } - shard_data['compression'] = 'zstd:16' + shard_data['zip_data'] = {'basename': '', 'bytes': avg_zip_shard_size, 'hashes': {}} + shard_data['compression'] = '' shards_list.append(shard_data) index_data['shards'] = shards_list @@ -76,8 +69,9 @@ def create_stream_index(shards: int, samples_per_shard: int, avg_raw_shard_size: foldername = get_random_foldername() os.mkdir(foldername) - with open(foldername + '/index.json', 'w') as f: + index_basename = get_index_basename() + + with open(f'{foldername}/{index_basename}', 'w') as f: json.dump(index_data, f) - f.close() - return os.path.join(foldername, 'index.json') + return os.path.join(foldername, index_basename) diff --git a/simulation/core/main.py b/simulation/core/main.py index ccd48275d..0cedbbb7b 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -3,11 +3,6 @@ """Main simulation function, simulating bytes downloaded and time taken each training step.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - import time from typing import Generator, Union diff --git a/simulation/core/node_tracker.py b/simulation/core/node_tracker.py index 73c6c03b7..2c39d660a 100644 --- a/simulation/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -3,11 +3,6 @@ """Class for tracking node information during simulation.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - from typing import Optional import numpy as np diff --git a/simulation/core/shard_downloads.py b/simulation/core/shard_downloads.py index 12d00b2c1..072b544f2 100644 --- a/simulation/core/shard_downloads.py +++ b/simulation/core/shard_downloads.py @@ -3,11 +3,6 @@ """Functions for simulating shard downloads and calculating needed cache limit for downloads.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - from typing import Optional import numpy as np diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index 4cf743883..bd1db828c 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -3,11 +3,6 @@ """Determine shuffle quality of a run over a fixed number of samples.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - import numpy as np from core.utils import remove_padded_samples from numpy.typing import NDArray diff --git a/simulation/core/simulation_dataset.py b/simulation/core/simulation_dataset.py index d1b0ba093..90e7267ef 100644 --- a/simulation/core/simulation_dataset.py +++ b/simulation/core/simulation_dataset.py @@ -3,11 +3,6 @@ """Near replica of StreamingDataset for simulation purposes.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - import os import shutil import time diff --git a/simulation/core/utils.py b/simulation/core/utils.py index e79b7345f..3fa57a161 100644 --- a/simulation/core/utils.py +++ b/simulation/core/utils.py @@ -3,11 +3,6 @@ """Peripheral functions for simulation functionality.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - import numpy as np from core.sim_time import Time, TimeUnit from core.simulation_dataset import SimulationDataset diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index bce22fcf4..0c1064b05 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -3,11 +3,6 @@ """Ingest yaml and create SimulationDataset.""" -import os.path -import sys - -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - from typing import Optional from core.sim_time import Time, TimeUnit, ensure_time diff --git a/simulation/interfaces/sim_script.py b/simulation/interfaces/sim_script.py index 7aeda2f0b..8ca0afff1 100644 --- a/simulation/interfaces/sim_script.py +++ b/simulation/interfaces/sim_script.py @@ -24,7 +24,7 @@ shards = 20850 # number of shards samples_per_shard = 4093 # number of samples per shard avg_raw_shard_size = 67092639 # average shard size (bytes) -avg_zip_shard_size = None # average compressed shard size (bytes) +avg_zip_shard_size = 15000000 # average compressed shard size (bytes) # training max_duration = '1000ba' # max duration of training (batches: "ba", epochs: "ep") From 63c83640b3fb0794b512e30f8633c08868313360 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 17 Oct 2023 13:32:12 -0700 Subject: [PATCH 23/31] added 'simulator' command for easy startup --- setup.py | 3 +++ simulation/__init__.py | 4 ++++ simulation/launcher.py | 10 ++++++++++ 3 files changed, 17 insertions(+) create mode 100644 simulation/__init__.py create mode 100644 simulation/launcher.py diff --git a/setup.py b/setup.py index f0bbe8216..1ecd91dd4 100644 --- a/setup.py +++ b/setup.py @@ -131,6 +131,9 @@ 'streaming': ['py.typed'], }, packages=setuptools.find_packages(exclude=['tests*']), + entry_points={ + 'console_scripts': ['simulator = simulation.launcher:launch_simulation_ui',], + }, classifiers=classifiers, install_requires=install_requires, extras_require=extra_deps, diff --git a/simulation/__init__.py b/simulation/__init__.py new file mode 100644 index 000000000..fedf64572 --- /dev/null +++ b/simulation/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Streaming simulation for throughput, network downloads, and shuffle quality.""" \ No newline at end of file diff --git a/simulation/launcher.py b/simulation/launcher.py new file mode 100644 index 000000000..ea864b67b --- /dev/null +++ b/simulation/launcher.py @@ -0,0 +1,10 @@ +# Copyright 2023 MosaicML Streaming authors +# SPDX-License-Identifier: Apache-2.0 + +"""Launch simulator UI from command line when this package is installed.""" + +import subprocess +import os + +def launch_simulation_ui(): + subprocess.run(["streamlit", "run", os.path.abspath("simulation/interfaces/sim_ui.py")]) \ No newline at end of file From bd9ed1b644ce716279fd74288ad9c9df2f8ed99f Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 17 Oct 2023 13:40:17 -0700 Subject: [PATCH 24/31] changed file prefixes to be consistent --- simulation/__init__.py | 2 +- simulation/core/main.py | 2 +- simulation/core/{simulation_dataset.py => sim_dataset.py} | 4 ++-- simulation/core/{simulation_spanner.py => sim_spanner.py} | 0 simulation/core/{simulation_world.py => sim_world.py} | 0 simulation/core/utils.py | 2 +- simulation/core/yaml_processing.py | 2 +- simulation/interfaces/{simcli.py => sim_cli.py} | 2 +- simulation/interfaces/sim_script.py | 2 +- simulation/interfaces/sim_ui.py | 2 +- simulation/launcher.py | 6 ++++-- simulation/testing/wandb_testing.py | 2 +- 12 files changed, 14 insertions(+), 12 deletions(-) rename simulation/core/{simulation_dataset.py => sim_dataset.py} (99%) rename simulation/core/{simulation_spanner.py => sim_spanner.py} (100%) rename simulation/core/{simulation_world.py => sim_world.py} (100%) rename simulation/interfaces/{simcli.py => sim_cli.py} (98%) diff --git a/simulation/__init__.py b/simulation/__init__.py index fedf64572..60468fe1f 100644 --- a/simulation/__init__.py +++ b/simulation/__init__.py @@ -1,4 +1,4 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""Streaming simulation for throughput, network downloads, and shuffle quality.""" \ No newline at end of file +"""Streaming simulation for throughput, network downloads, and shuffle quality.""" diff --git a/simulation/core/main.py b/simulation/core/main.py index 0cedbbb7b..116ba0e96 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -9,8 +9,8 @@ import numpy as np from core.node_tracker import NodeTracker from core.shard_downloads import run_cache_limit, simulate_shard_downloads +from core.sim_dataset import SimulationDataset from core.sim_time import Time -from core.simulation_dataset import SimulationDataset from core.utils import bytes_to_time, get_batches_epochs, time_to_bytes from numpy.typing import NDArray diff --git a/simulation/core/simulation_dataset.py b/simulation/core/sim_dataset.py similarity index 99% rename from simulation/core/simulation_dataset.py rename to simulation/core/sim_dataset.py index 90e7267ef..79677e7c2 100644 --- a/simulation/core/simulation_dataset.py +++ b/simulation/core/sim_dataset.py @@ -11,8 +11,8 @@ from typing import Optional, Sequence, Union import numpy as np -from core.simulation_spanner import SimulationSpanner -from core.simulation_world import SimulationWorld +from core.sim_spanner import SimulationSpanner +from core.sim_world import SimulationWorld from numpy.typing import NDArray from streaming.base import Stream, StreamingDataset diff --git a/simulation/core/simulation_spanner.py b/simulation/core/sim_spanner.py similarity index 100% rename from simulation/core/simulation_spanner.py rename to simulation/core/sim_spanner.py diff --git a/simulation/core/simulation_world.py b/simulation/core/sim_world.py similarity index 100% rename from simulation/core/simulation_world.py rename to simulation/core/sim_world.py diff --git a/simulation/core/utils.py b/simulation/core/utils.py index 3fa57a161..a89a02267 100644 --- a/simulation/core/utils.py +++ b/simulation/core/utils.py @@ -4,8 +4,8 @@ """Peripheral functions for simulation functionality.""" import numpy as np +from core.sim_dataset import SimulationDataset from core.sim_time import Time, TimeUnit -from core.simulation_dataset import SimulationDataset from numpy.typing import NDArray diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index 0c1064b05..d70ba2edc 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -5,8 +5,8 @@ from typing import Optional +from core.sim_dataset import SimulationDataset from core.sim_time import Time, TimeUnit, ensure_time -from core.simulation_dataset import SimulationDataset from omegaconf import DictConfig from omegaconf import OmegaConf as om diff --git a/simulation/interfaces/simcli.py b/simulation/interfaces/sim_cli.py similarity index 98% rename from simulation/interfaces/simcli.py rename to simulation/interfaces/sim_cli.py index 52cfad864..6931afa4e 100644 --- a/simulation/interfaces/simcli.py +++ b/simulation/interfaces/sim_cli.py @@ -1,7 +1,7 @@ # Copyright 2023 MosaicML Streaming authors # SPDX-License-Identifier: Apache-2.0 -"""simcli: simulate your training yaml from the command line.""" +"""sim_cli: simulate your training yaml from the command line.""" import os.path import sys diff --git a/simulation/interfaces/sim_script.py b/simulation/interfaces/sim_script.py index 8ca0afff1..7fd62d234 100644 --- a/simulation/interfaces/sim_script.py +++ b/simulation/interfaces/sim_script.py @@ -11,8 +11,8 @@ import humanize from core.create_index import create_stream_index from core.main import simulate +from core.sim_dataset import SimulationDataset from core.sim_time import TimeUnit, ensure_time -from core.simulation_dataset import SimulationDataset from core.utils import get_simulation_stats from interfaces.interface_utils import plot_simulation diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py index 52d74189d..56dd9dfc7 100644 --- a/simulation/interfaces/sim_ui.py +++ b/simulation/interfaces/sim_ui.py @@ -19,8 +19,8 @@ from core.create_index import create_stream_index from core.main import simulate from core.shuffle_quality import analyze_shuffle_quality +from core.sim_dataset import SimulationDataset from core.sim_time import Time -from core.simulation_dataset import SimulationDataset from core.utils import get_total_batches from core.yaml_processing import create_simulation_dataset, ingest_yaml from interfaces.interface_utils import get_train_dataset_params diff --git a/simulation/launcher.py b/simulation/launcher.py index ea864b67b..6fea1f80e 100644 --- a/simulation/launcher.py +++ b/simulation/launcher.py @@ -3,8 +3,10 @@ """Launch simulator UI from command line when this package is installed.""" -import subprocess import os +import subprocess + def launch_simulation_ui(): - subprocess.run(["streamlit", "run", os.path.abspath("simulation/interfaces/sim_ui.py")]) \ No newline at end of file + """Launch the simulation UI.""" + subprocess.run(['streamlit', 'run', os.path.abspath('simulation/interfaces/sim_ui.py')]) diff --git a/simulation/testing/wandb_testing.py b/simulation/testing/wandb_testing.py index e3a619d87..61ea1a328 100644 --- a/simulation/testing/wandb_testing.py +++ b/simulation/testing/wandb_testing.py @@ -16,8 +16,8 @@ import wandb from core.create_index import create_stream_index from core.main import simulate +from core.sim_dataset import SimulationDataset from core.sim_time import TimeUnit, ensure_time -from core.simulation_dataset import SimulationDataset from numpy.typing import NDArray from streaming.base import Stream From eeaec7429b369148cd2609e1c7ef4db317755842 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 17 Oct 2023 15:11:57 -0700 Subject: [PATCH 25/31] shuffle quality metric is now relative to naive --- simulation/core/shuffle_quality.py | 75 ++++++++++-------------------- simulation/interfaces/sim_ui.py | 8 ++-- simulation/interfaces/widgets.py | 22 +++++++-- 3 files changed, 47 insertions(+), 58 deletions(-) diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index bd1db828c..3428bdefc 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -42,9 +42,14 @@ def get_entropy(ordering: NDArray) -> float: return float(diff_entropy) -def get_partition_shard_info(epoch_size: int, canonical_nodes: int, physical_nodes: int, - devices: int, workers: int, device_batch_size: int, - samples_per_shard: int) -> tuple[NDArray, NDArray, NDArray]: +def get_partition_shard_info(epoch_size: int, + canonical_nodes: int, + physical_nodes: int, + devices: int, + workers: int, + device_batch_size: int, + samples_per_shard: int, + remove_padding: bool = False) -> tuple[NDArray, NDArray, NDArray]: """Partition up to 100 million samples and get associated shard information. Args: @@ -55,6 +60,7 @@ def get_partition_shard_info(epoch_size: int, canonical_nodes: int, physical_nod workers (int): The number of workers. device_batch_size (int): The batch size per device. samples_per_shard (int): Average number of samples per shard. + remove_padding (bool): Whether to remove padding samples. Defaults to ``False``. Returns: tuple[NDArray, NDArray, NDArray]: The partition, in order, the @@ -68,7 +74,8 @@ def get_partition_shard_info(epoch_size: int, canonical_nodes: int, physical_nod partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, devices, workers, device_batch_size) partition = partition.transpose(3, 2, 0, 1, 4).flatten() - partition = remove_padded_samples(partition) + if remove_padding: + partition = remove_padded_samples(partition) # Construct shard sizes array. num_shards = num_samples // samples_per_shard @@ -100,7 +107,7 @@ def get_entropy_shuffle_quality(shuffle_algo: str, partition: NDArray, shard_siz shuffle_block_size (int): The shuffle block size. Returns: - float: The entropy of the shuffle, combining entropy from sample and shard orderings. + float: The entropy metric, equal to the harmonic mean of sample and shard orderings. """ if shuffle_algo != 'none': # Assume we are shuffling only for epoch 0. @@ -109,50 +116,13 @@ def get_entropy_shuffle_quality(shuffle_algo: str, partition: NDArray, shard_siz partition = shuffle_ordering[partition] sample_entropy = get_entropy(partition) shard_entropy = get_entropy(shard_per_sample[partition]) - return sample_entropy + shard_entropy + return (2 * sample_entropy * shard_entropy) / (sample_entropy + shard_entropy) -def analyze_all_shuffle_quality(algos: list[str], canonical_nodes: int, physical_nodes: int, - devices: int, workers: int, device_batch_size: int, - shuffle_block_size: int, samples_per_shard: int, epoch_size: int, - seed: int) -> list[tuple[str, float]]: - """Analyze the quality of this shuffle across algorithms. - - Args: - algos (list[str]): The algorithms to analyze. - canonical_nodes (int): The number of canonical nodes. - physical_nodes (int): The number of physical nodes. - devices (int): The number of devices. - workers (int): The number of workers. - device_batch_size (int): The batch size per device. - shuffle_block_size (int): The shuffle block size. - samples_per_shard (int): Average number of samples per shard. - epoch_size (int): The number of samples in an epoch. - seed (int): The seed to use for the shuffle. - - Returns: - list[tuple[str, float]]: Shuffle algorithms and shuffle qualities. - """ - print('Analyzing shuffle quality...') - - shuffle_qualities = [] - - # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. - partition, shard_sizes, shard_per_sample = get_partition_shard_info( - epoch_size, canonical_nodes, physical_nodes, devices, workers, device_batch_size, - samples_per_shard) - for algo in algos: - shuffle_qualities.append( - get_entropy_shuffle_quality(algo, partition, shard_sizes, shard_per_sample, - canonical_nodes, seed, shuffle_block_size)) - - return shuffle_qualities - - -def analyze_shuffle_quality(algo: str, canonical_nodes: int, physical_nodes: int, devices: int, - workers: int, device_batch_size: int, shuffle_block_size: int, - samples_per_shard: int, epoch_size: int, - seed: int) -> tuple[str, float]: +def analyze_shuffle_quality_entropy(algo: str, canonical_nodes: int, physical_nodes: int, + devices: int, workers: int, device_batch_size: int, + shuffle_block_size: int, samples_per_shard: int, + epoch_size: int, seed: int) -> tuple[str, float]: """Analyze the quality of a shuffle for one algorithm. Args: @@ -173,9 +143,14 @@ def analyze_shuffle_quality(algo: str, canonical_nodes: int, physical_nodes: int print(f'Analyzing shuffle quality for {algo}...') # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. - partition, shard_sizes, shard_per_sample = get_partition_shard_info( - epoch_size, canonical_nodes, physical_nodes, devices, workers, device_batch_size, - samples_per_shard) + partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, + canonical_nodes, + physical_nodes, + devices, + workers, + device_batch_size, + samples_per_shard, + remove_padding=True) shuffle_quality = get_entropy_shuffle_quality(algo, partition, shard_sizes, shard_per_sample, canonical_nodes, seed, shuffle_block_size) diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py index 56dd9dfc7..f6cd8ed98 100644 --- a/simulation/interfaces/sim_ui.py +++ b/simulation/interfaces/sim_ui.py @@ -18,7 +18,7 @@ import yaml from core.create_index import create_stream_index from core.main import simulate -from core.shuffle_quality import analyze_shuffle_quality +from core.shuffle_quality import analyze_shuffle_quality_entropy from core.sim_dataset import SimulationDataset from core.sim_time import Time from core.utils import get_total_batches @@ -99,9 +99,9 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp seed = input_params['seed'] # Submit all shuffle quality analysis jobs to executor. futures = [ - executor.submit(analyze_shuffle_quality, algo, canonical_nodes, physical_nodes, - devices, workers, device_batch_size, shuffle_block_size, - samples_per_shard, epoch_size, seed) + executor.submit(analyze_shuffle_quality_entropy, algo, canonical_nodes, + physical_nodes, devices, workers, device_batch_size, + shuffle_block_size, samples_per_shard, epoch_size, seed) for algo in shuffle_quality_algos ] diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index e0e0cd214..7e9d1070f 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -412,8 +412,11 @@ def get_shuffle_quality_chart(data: pd.DataFrame) -> alt.Chart: Returns: alt.Chart: Interactive bar chart for shuffle quality. """ - bars = (alt.Chart(data, title='Shuffle Quality (higher is better)').mark_bar().encode( - x='algo', y='quality', tooltip='quality').properties(width=550,)) + bars = (alt.Chart(data, + title='Relative Shuffle Quality (1 is best)').mark_bar().encode( + x='shuffling algorithm', + y='relative quality', + tooltip='relative quality').properties(width=550,)) return bars.interactive() @@ -427,6 +430,17 @@ def display_shuffle_quality_graph(futures: list[Future], component: DeltaGenerat # Retrieve shuffle quality result since it is available shuffle_algos_qualities = list(zip(*[f.result() for f in futures])) shuffle_algos = list(shuffle_algos_qualities[0]) - shuffle_qualities = list(shuffle_algos_qualities[1]) - shuffle_quality_df = pd.DataFrame({'algo': shuffle_algos, 'quality': shuffle_qualities}) + # divide all shuffle qualities by naive shuffle quality to get a relative measure + naive_idx = shuffle_algos.index('naive') + naive_shuffle_quality = shuffle_algos_qualities[1][naive_idx] + shuffle_algos.remove('naive') + shuffle_qualities = [ + shuffle_algos_qualities[1][i] / naive_shuffle_quality + for i in range(len(shuffle_algos_qualities[1])) + if i != naive_idx + ] + shuffle_quality_df = pd.DataFrame({ + 'shuffling algorithm': shuffle_algos, + 'relative quality': shuffle_qualities + }) component.altair_chart(get_shuffle_quality_chart(shuffle_quality_df), use_container_width=True) From da269becac62300d5e6719b0591c5eff7e6a012e Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 17 Oct 2023 15:17:22 -0700 Subject: [PATCH 26/31] tuple to Tuple --- simulation/core/main.py | 10 +++++----- simulation/core/node_tracker.py | 6 +++--- simulation/core/shard_downloads.py | 6 +++--- simulation/core/shuffle_quality.py | 10 ++++++---- simulation/core/utils.py | 10 ++++++---- simulation/core/yaml_processing.py | 6 +++--- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/simulation/core/main.py b/simulation/core/main.py index 116ba0e96..8a9889ead 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -4,7 +4,7 @@ """Main simulation function, simulating bytes downloaded and time taken each training step.""" import time -from typing import Generator, Union +from typing import Generator, Tuple, Union import numpy as np from core.node_tracker import NodeTracker @@ -21,7 +21,7 @@ def simulate( node_network_bandwidth: int, max_duration: Time, generator: bool = False -) -> Generator[Union[tuple[int, float, int], tuple[float, int], tuple[NDArray, NDArray, float, +) -> Generator[Union[Tuple[int, float, int], Tuple[float, int], Tuple[NDArray, NDArray, float, int]], None, None]: """Simulates step time and downloads using streaming for the specified input parameters. @@ -52,9 +52,9 @@ def simulate( generator (bool): True if we yield throughput and shard_download one step at a time. Returns: - Generator[Union[tuple[int, float, int], - tuple[NDArray, NDArray, float, int], - tuple[float, int]], None, None]: either a tuple of step number, step time, and + Generator[Union[Tuple[int, float, int], + Tuple[NDArray, NDArray, float, int], + Tuple[float, int]], None, None]: either a tuple of step number, step time, and downloaded bytes, a tuple of startup time and min needed cache limit, (both when generator=True), or a tuple of all step times, downloaded bytes, startup_time, and min needed cache limit. diff --git a/simulation/core/node_tracker.py b/simulation/core/node_tracker.py index 2c39d660a..1fd895c8f 100644 --- a/simulation/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -3,7 +3,7 @@ """Class for tracking node information during simulation.""" -from typing import Optional +from typing import Optional, Tuple import numpy as np from core.last_used_ordered_set import LastUsedOrderedSet @@ -151,7 +151,7 @@ def get_worker_download(self, raise ValueError('Must specify either index, or worker and device.') def get_current_batch_shards(self, worker: int, worker_sample_index: int, - sample_to_shard: Spanner) -> tuple[set, set]: + sample_to_shard: Spanner) -> Tuple[set, set]: """Get this node's shards for the current batch. Args: @@ -160,7 +160,7 @@ def get_current_batch_shards(self, worker: int, worker_sample_index: int, sample_to_shard (Spanner): The mapping from samples to shards. Returns: - tuple[set, set]: shard ids needed by node, shard ids present in node. + Tuple[set, set]: shard ids needed by node, shard ids present in node. """ if self.samples is not None: batch_samples = remove_padded_samples( diff --git a/simulation/core/shard_downloads.py b/simulation/core/shard_downloads.py index 072b544f2..02b05a0dd 100644 --- a/simulation/core/shard_downloads.py +++ b/simulation/core/shard_downloads.py @@ -3,7 +3,7 @@ """Functions for simulating shard downloads and calculating needed cache limit for downloads.""" -from typing import Optional +from typing import Optional, Tuple import numpy as np from core.node_tracker import NodeTracker @@ -17,7 +17,7 @@ def simulate_shard_downloads(node: NodeTracker, step_num: int, cache_limit: Optional[int] = None, shards_needed: Optional[set] = None, - download_bytes_left: Optional[int] = None) -> tuple[str, int]: + download_bytes_left: Optional[int] = None) -> Tuple[str, int]: """Simulate downloading a shard for a node. Args: @@ -33,7 +33,7 @@ def simulate_shard_downloads(node: NodeTracker, time interval. Defaults to ``None``. Returns: - tuple[bool, int]: A tuple of the shard download status and the download size. + Tuple[bool, int]: A tuple of the shard download status and the download size. """ worker_download = node.get_next_worker_with_downloads() if worker_download is None: diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index 3428bdefc..cb7f7ba3f 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -3,6 +3,8 @@ """Determine shuffle quality of a run over a fixed number of samples.""" +from typing import Tuple + import numpy as np from core.utils import remove_padded_samples from numpy.typing import NDArray @@ -49,7 +51,7 @@ def get_partition_shard_info(epoch_size: int, workers: int, device_batch_size: int, samples_per_shard: int, - remove_padding: bool = False) -> tuple[NDArray, NDArray, NDArray]: + remove_padding: bool = False) -> Tuple[NDArray, NDArray, NDArray]: """Partition up to 100 million samples and get associated shard information. Args: @@ -63,7 +65,7 @@ def get_partition_shard_info(epoch_size: int, remove_padding (bool): Whether to remove padding samples. Defaults to ``False``. Returns: - tuple[NDArray, NDArray, NDArray]: The partition, in order, the + Tuple[NDArray, NDArray, NDArray]: The partition, in order, the sizes of each shard, and the mapping of sample id to shard id. """ num_samples = epoch_size @@ -122,7 +124,7 @@ def get_entropy_shuffle_quality(shuffle_algo: str, partition: NDArray, shard_siz def analyze_shuffle_quality_entropy(algo: str, canonical_nodes: int, physical_nodes: int, devices: int, workers: int, device_batch_size: int, shuffle_block_size: int, samples_per_shard: int, - epoch_size: int, seed: int) -> tuple[str, float]: + epoch_size: int, seed: int) -> Tuple[str, float]: """Analyze the quality of a shuffle for one algorithm. Args: @@ -138,7 +140,7 @@ def analyze_shuffle_quality_entropy(algo: str, canonical_nodes: int, physical_no seed (int): The seed to use for the shuffle. Returns: - tuple[str, float]: Shuffle algorithm and shuffle quality. + Tuple[str, float]: Shuffle algorithm and shuffle quality. """ print(f'Analyzing shuffle quality for {algo}...') diff --git a/simulation/core/utils.py b/simulation/core/utils.py index a89a02267..c03a96acb 100644 --- a/simulation/core/utils.py +++ b/simulation/core/utils.py @@ -3,13 +3,15 @@ """Peripheral functions for simulation functionality.""" +from typing import Tuple + import numpy as np from core.sim_dataset import SimulationDataset from core.sim_time import Time, TimeUnit from numpy.typing import NDArray -def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> tuple[int, int, int]: +def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> Tuple[int, int, int]: """Get batches per epoch, epochs, and total epochs from a Time object. Args: @@ -17,7 +19,7 @@ def get_batches_epochs(dataset: SimulationDataset, max_duration: Time) -> tuple[ max_duration (Time): The maximum duration, can be specified in yaml. Returns: - tuple[int, int, int]: batches per epoch, epochs, and the total batches. + Tuple[int, int, int]: batches per epoch, epochs, and the total batches. """ # get epochs, batches_per_epoch, and total_batches from a Time obect dataset_batches = dataset.get_num_batches() @@ -126,7 +128,7 @@ def get_rolling_avg_throughput(step_times: NDArray, window: int = 10) -> NDArray def get_simulation_stats(step_times: NDArray, time_per_sample: float, - device_batch_size: int) -> tuple[int, float, int, int]: + device_batch_size: int) -> Tuple[int, float, int, int]: """Gets simulation stats for web UI. Args: @@ -135,7 +137,7 @@ def get_simulation_stats(step_times: NDArray, time_per_sample: float, device_batch_size (int): batch size per device Returns: - tuple[int, float, int, int]: number of steps with throughput drops, time till warmup, + Tuple[int, float, int, int]: number of steps with throughput drops, time till warmup, step number of warmup, number of steps with throughput drops after warmup """ # calculate percent of download-limited steps diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index d70ba2edc..24063e8f2 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -3,7 +3,7 @@ """Ingest yaml and create SimulationDataset.""" -from typing import Optional +from typing import Optional, Tuple from core.sim_dataset import SimulationDataset from core.sim_time import Time, TimeUnit, ensure_time @@ -14,7 +14,7 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, - filepath: Optional[str] = None) -> tuple[Optional[int], int, Time, int, dict]: + filepath: Optional[str] = None) -> Tuple[Optional[int], int, Time, int, dict]: """Create SimulationDataset from yaml file and other needed args. Args: @@ -22,7 +22,7 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, filepath (Optional[str]): path to yaml file Returns: - tuple[Optional[int], Optional[int], Time, Optional[int], Optional[dict]]: total_devices, + Tuple[Optional[int], Optional[int], Time, Optional[int], Optional[dict]]: total_devices, workers, max_duration, global_batch_size, train_dataset parameters from yaml """ config = None From 18488e2df7f39cbfa5f516583c36dcf487f9a548 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Tue, 17 Oct 2023 15:47:49 -0700 Subject: [PATCH 27/31] added docs, deleted redundant images folder --- .../source/_static/images}/downloads.png | Bin .../source/_static/images}/inputs.png | Bin .../_static/images}/shuffle_quality_graph.png | Bin .../images}/shuffle_quality_toggle.png | Bin .../source/_static/images}/stats.png | Bin .../source/_static/images}/throughput.png | Bin .../source/_static/images}/yaml_toggle.png | Bin docs/source/fundamentals/simulator.md | 45 ++++++++++++++++++ docs/source/index.md | 1 + simulation/README.md | 22 ++++----- 10 files changed, 56 insertions(+), 12 deletions(-) rename {simulation/imgs => docs/source/_static/images}/downloads.png (100%) rename {simulation/imgs => docs/source/_static/images}/inputs.png (100%) rename {simulation/imgs => docs/source/_static/images}/shuffle_quality_graph.png (100%) rename {simulation/imgs => docs/source/_static/images}/shuffle_quality_toggle.png (100%) rename {simulation/imgs => docs/source/_static/images}/stats.png (100%) rename {simulation/imgs => docs/source/_static/images}/throughput.png (100%) rename {simulation/imgs => docs/source/_static/images}/yaml_toggle.png (100%) create mode 100644 docs/source/fundamentals/simulator.md diff --git a/simulation/imgs/downloads.png b/docs/source/_static/images/downloads.png similarity index 100% rename from simulation/imgs/downloads.png rename to docs/source/_static/images/downloads.png diff --git a/simulation/imgs/inputs.png b/docs/source/_static/images/inputs.png similarity index 100% rename from simulation/imgs/inputs.png rename to docs/source/_static/images/inputs.png diff --git a/simulation/imgs/shuffle_quality_graph.png b/docs/source/_static/images/shuffle_quality_graph.png similarity index 100% rename from simulation/imgs/shuffle_quality_graph.png rename to docs/source/_static/images/shuffle_quality_graph.png diff --git a/simulation/imgs/shuffle_quality_toggle.png b/docs/source/_static/images/shuffle_quality_toggle.png similarity index 100% rename from simulation/imgs/shuffle_quality_toggle.png rename to docs/source/_static/images/shuffle_quality_toggle.png diff --git a/simulation/imgs/stats.png b/docs/source/_static/images/stats.png similarity index 100% rename from simulation/imgs/stats.png rename to docs/source/_static/images/stats.png diff --git a/simulation/imgs/throughput.png b/docs/source/_static/images/throughput.png similarity index 100% rename from simulation/imgs/throughput.png rename to docs/source/_static/images/throughput.png diff --git a/simulation/imgs/yaml_toggle.png b/docs/source/_static/images/yaml_toggle.png similarity index 100% rename from simulation/imgs/yaml_toggle.png rename to docs/source/_static/images/yaml_toggle.png diff --git a/docs/source/fundamentals/simulator.md b/docs/source/fundamentals/simulator.md new file mode 100644 index 000000000..59437d29f --- /dev/null +++ b/docs/source/fundamentals/simulator.md @@ -0,0 +1,45 @@ +# Streaming Simulator +A simulator for throughput, network use, and shuffle quality with MosaicML Streaming. The simulator allows you to: +- Plan runs and anticipate issues beforehand +- Find optimal run configurations +- Debug issues with underperforming runs +- Better understand the impact of different configurations + +## Getting Started +Run the following to install simulator-specific dependencies, if they don't already exist: +``` +pip install --upgrade "mosaicml-streaming[simulator]" +``` +Then, simply run `simulator` in your command line to open the Web UI and get simulating! +## Key Features + +### Throughput +Throughput is estimated for the duration of the run and is displayed as the simulation progresses. We estimate throughput by iterating over the samples of the dataset in order, and performing shard downloads based on an estimate of network bandwidth. The 10-step rolling average is displayed. + +Throughput Graph + +### Network Downloads +Cumulative network downloads are also estimated for the run and displayed. It is calculated in conjunction with throughput. If shards are compressed, we assume they are downloaded in compressed form and immediately uncompressed. + +Downloads Graph + +### Simulation Stats +We also provide various useful statistics from the simulation, such as: +- Minimum cache limit (i.e., maximum space used by live shards) +- Steps slowed down by shard downloads +- Estimated time to first batch +- Estimated warmup time (i.e., time until throughput maximized) + +Simulation Stats + +### Shuffle Quality +You can choose to evaluate the quality of different shuffling algorithms for your run. We provide an estimate of shuffle quality based on the entropy calculated over the probability distribution of differences between neighboring sample indices and shard indices of the dataset. *These shuffle quality metrics are noisy and may not reflect the true strength of a shuffle.* + +Shuffle Quality Toggle + +Shuffle Quality Graph + +### Yaml Support +Yaml files that follow MosaicML conventions can be uploaded and simulated as well. Simply click the toggle, enter any needed additional information, and see your results. Parameters can also be modified to quickly test out configurations. + +Yaml Quality Toggle diff --git a/docs/source/index.md b/docs/source/index.md index 6728583ad..0bc2cda55 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -60,6 +60,7 @@ If you have any questions, please feel free to reach out to us on [Twitter](htt fundamentals/shuffling.md fundamentals/sampling.md fundamentals/batching.md + fundamentals/simulator.md .. toctree:: :hidden: diff --git a/simulation/README.md b/simulation/README.md index e3a40b57b..89f48a0a0 100644 --- a/simulation/README.md +++ b/simulation/README.md @@ -1,29 +1,27 @@ # 🤖 Streaming Simulator -A simulator for throughput and network use with MosaicML's [Streaming](https://github.com/mosaicml/streaming). The simulator allows you to: +A simulator for throughput, network use, and shuffle quality with MosaicML Streaming. The simulator allows you to: - Plan runs and anticipate issues beforehand - Find optimal run configurations - Debug issues with underperforming runs - Better understand the impact of different configurations ## 🚀 Getting Started -Run the commands below to get simulating! +Run the following to install simulator-specific dependencies, if they don't already exist: ``` -git clone https://github.com/mosaicml/streaming.git -cd streaming -pip install ".[simulator]" -make simulator +pip install --upgrade "mosaicml-streaming[simulator]" ``` +Then, simply run `simulator` in your command line to open the Web UI and get simulating! ## 🔑 Key Features ### Throughput Throughput is estimated for the duration of the run and is displayed as the simulation progresses. We estimate throughput by iterating over the samples of the dataset in order, and performing shard downloads based on an estimate of network bandwidth. The 10-step rolling average is displayed. -![Throughput Graph](imgs/throughput.png) +![Throughput Graph](../docs/source/_static/images/throughput.png) ### Network Downloads Cumulative network downloads are also estimated for the run and displayed. It is calculated in conjunction with throughput. If shards are compressed, we assume they are downloaded in compressed form and immediately uncompressed. -![Downloads Graph](imgs/downloads.png) +![Downloads Graph](../docs/source/_static/images/downloads.png) ### Simulation Stats We also provide various useful statistics from the simulation, such as: @@ -32,19 +30,19 @@ We also provide various useful statistics from the simulation, such as: - Estimated time to first batch - Estimated warmup time (i.e., time until throughput maximized) -![Simulation Stats](imgs/stats.png) +![Simulation Stats](../docs/source/_static/images/stats.png) ### Shuffle Quality You can choose to evaluate the quality of different shuffling algorithms for your run. We provide an estimate of shuffle quality based on the entropy calculated over the probability distribution of differences between neighboring sample indices and shard indices of the dataset. *These shuffle quality metrics are noisy and may not reflect the true strength of a shuffle.* -![Shuffle Quality Toggle](imgs/shuffle_quality_toggle.png) +![Shuffle Quality Toggle](../docs/source/_static/images/shuffle_quality_toggle.png) -![Shuffle Quality Graph](imgs/shuffle_quality_graph.png) +![Shuffle Quality Graph](../docs/source/_static/images/shuffle_quality_graph.png) ### Yaml Support Yaml files that follow MosaicML conventions can be uploaded and simulated as well. Simply click the toggle, enter any needed additional information, and see your results. Parameters can also be modified to quickly test out configurations. -![Yaml Quality Toggle](imgs/yaml_toggle.png) +![Yaml Quality Toggle](../docs/source/_static/images/yaml_toggle.png) ## 💬 Contact If you have problems, questions, or suggestions, please reach out to the MosaicML team on our [community slack channel](https://mosaicml.me/slack). From 4cc26558aefd0e62af8a78b6e7748ee36441cd65 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Wed, 18 Oct 2023 15:18:25 -0700 Subject: [PATCH 28/31] addressed Karan comments --- setup.py | 2 ++ simulation/core/create_index.py | 19 +++++++--- simulation/core/main.py | 10 ++++-- simulation/core/node_tracker.py | 2 +- simulation/core/shuffle_quality.py | 13 ++++--- simulation/core/sim_dataset.py | 16 +++++---- simulation/core/sim_time.py | 55 +++-------------------------- simulation/interfaces/sim_script.py | 2 +- simulation/requirements.txt | 10 ------ simulation/testing/wandb_testing.py | 8 +++-- 10 files changed, 54 insertions(+), 83 deletions(-) delete mode 100644 simulation/requirements.txt diff --git a/setup.py b/setup.py index 1ecd91dd4..69c2a5d6b 100644 --- a/setup.py +++ b/setup.py @@ -99,6 +99,8 @@ 'altair>=5.1.1,<6', 'omegaconf>=2.3.0,<3', 'PyYAML>=6.0,<7', + 'pandas>=2.0.3,<3', + 'wandb>=0.15.5,<1', ] extra_deps['spark'] = [ diff --git a/simulation/core/create_index.py b/simulation/core/create_index.py index a78b5524a..41e356c71 100644 --- a/simulation/core/create_index.py +++ b/simulation/core/create_index.py @@ -4,6 +4,7 @@ """Create a dataset index file from input parameters.""" import json +import logging import os import random import string @@ -11,9 +12,16 @@ from streaming.base.format import get_index_basename +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) -def get_random_foldername(): - """Generate random folder name to store the index file in.""" + +def get_random_foldername() -> str: + """Generate random folder name to store the index file in. + + Returns: + str: random alphanumeric folder name. + """ return ''.join( random.choice(string.ascii_uppercase + string.ascii_lowercase + string.digits) for _ in range(16)) @@ -65,13 +73,14 @@ def create_stream_index(shards: int, samples_per_shard: int, avg_raw_shard_size: try: os.mkdir(foldername) except FileExistsError: - print('Folder already exists, trying again...') + logger.warning(' Folder already exists, trying again...') foldername = get_random_foldername() os.mkdir(foldername) index_basename = get_index_basename() + index_path = os.path.join(foldername, index_basename) - with open(f'{foldername}/{index_basename}', 'w') as f: + with open(index_path, 'w') as f: json.dump(index_data, f) - return os.path.join(foldername, index_basename) + return index_path diff --git a/simulation/core/main.py b/simulation/core/main.py index 8a9889ead..e62051110 100644 --- a/simulation/core/main.py +++ b/simulation/core/main.py @@ -3,6 +3,7 @@ """Main simulation function, simulating bytes downloaded and time taken each training step.""" +import logging import time from typing import Generator, Tuple, Union @@ -14,6 +15,9 @@ from core.utils import bytes_to_time, get_batches_epochs, time_to_bytes from numpy.typing import NDArray +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + def simulate( dataset: SimulationDataset, @@ -125,10 +129,10 @@ def simulate( if step_num >= total_batches: break - # Print progress every notification_batches interval. + # Log progress every notification_batches interval. if (batch + 1) % notification_batches == 0: - print('Epoch: ' + str(epoch + 1) + ' | Batch ' + str(batch + 1) + '/' + - str(batches_per_epoch)) + logger.info( + f' Epoch: {str(epoch + 1)} | Batch {str(batch + 1)}/{str(batches_per_epoch)}') # We round-robin over workers per device. The current batch's worker is the same # across every device. diff --git a/simulation/core/node_tracker.py b/simulation/core/node_tracker.py index 1fd895c8f..05933227c 100644 --- a/simulation/core/node_tracker.py +++ b/simulation/core/node_tracker.py @@ -23,7 +23,7 @@ class NodeTracker(): predownload (int): The number of samples to predownload. device_batch_size (int): The device batch size. total_shards (int): Total number of shards in the dataset. - cache_limit (Optional[int]): The cache limit for the node. Defaults to None. + cache_limit (Optional[int]): The cache limit for the node. Defaults to ``None``. """ def __init__(self, diff --git a/simulation/core/shuffle_quality.py b/simulation/core/shuffle_quality.py index cb7f7ba3f..38cb16dab 100644 --- a/simulation/core/shuffle_quality.py +++ b/simulation/core/shuffle_quality.py @@ -3,6 +3,7 @@ """Determine shuffle quality of a run over a fixed number of samples.""" +import logging from typing import Tuple import numpy as np @@ -12,6 +13,9 @@ from streaming.base.partition.orig import get_partitions_orig from streaming.base.shuffle import get_shuffle +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + def get_entropy(ordering: NDArray) -> float: """Calculate the entropy of an ordering, which is initially assumed to be in ascending order. @@ -69,9 +73,10 @@ def get_partition_shard_info(epoch_size: int, sizes of each shard, and the mapping of sample id to shard id. """ num_samples = epoch_size - if num_samples > 100000000: - print('Epoch size is >100 million. Using 100 million samples to analyze shuffle quality.') - num_samples = 100000000 + if num_samples > 100_000_000: + logger.warning( + ' Epoch size is >100 million. Using 100 million samples to analyze shuffle quality.') + num_samples = 100_000_000 partition = get_partitions_orig(num_samples, canonical_nodes, physical_nodes, devices, workers, device_batch_size) @@ -142,7 +147,7 @@ def analyze_shuffle_quality_entropy(algo: str, canonical_nodes: int, physical_no Returns: Tuple[str, float]: Shuffle algorithm and shuffle quality. """ - print(f'Analyzing shuffle quality for {algo}...') + logger.info(f' Analyzing shuffle quality for {algo}...') # Getting partition, shard_sizes, and shard_per_sample only has to be done once for all algos. partition, shard_sizes, shard_per_sample = get_partition_shard_info(epoch_size, diff --git a/simulation/core/sim_dataset.py b/simulation/core/sim_dataset.py index 79677e7c2..36b37173e 100644 --- a/simulation/core/sim_dataset.py +++ b/simulation/core/sim_dataset.py @@ -3,6 +3,7 @@ """Near replica of StreamingDataset for simulation purposes.""" +import logging import os import shutil import time @@ -21,6 +22,9 @@ from streaming.base.spanner import Spanner from streaming.base.util import bytes_to_int, number_abbrev_to_int +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + class SimulationDataset(StreamingDataset): """Near replica of StreamingDataset for simulation purposes. @@ -235,7 +239,7 @@ def __init__(self, index_filenames = [] local_foldernames = [] for stream_id, stream in enumerate(self.streams): - print('Processing index file for stream', stream_id + 1) + logger.info(f' Processing index file for stream {stream_id + 1}') stream_shards = stream.get_shards(self.world) num_stream_samples = sum(map(len, stream_shards)) index_filename = os.path.join(stream.local, stream.split, get_index_basename()) @@ -289,10 +293,10 @@ def __init__(self, self.zip_shard_sizes = np.array([shard.get_zip_size() or 0 for shard in self.shards], np.int64) - print('Total number of shards:', self.num_shards) - print('Average number of samples per shard:', self.num_samples / self.num_shards) - print('Average raw shard size (bytes):', np.mean(self.raw_shard_sizes)) - print('Average zip shard size (bytes):', np.mean(self.zip_shard_sizes)) + logger.info(f' Total number of shards: {self.num_shards}') + logger.info(f' Average number of samples per shard: {self.num_samples / self.num_shards}') + logger.info(f' Average raw shard size (bytes): {np.mean(self.raw_shard_sizes)}') + logger.info(f' Average zip shard size (bytes): {np.mean(self.zip_shard_sizes)}') # Now that we know the number of underlying samples of each stream, derive each stream's # true proportion/repeat/choose, as well as the total epoch size. @@ -305,7 +309,7 @@ def __init__(self, t1 = time.time() self.instantiation_time = t1 - t0 - print('SimulationDataset created successfully.') + logger.info(' SimulationDataset created successfully.') def get_sample_partition(self, epoch: int, sample_in_epoch: int) -> NDArray: """Get the dataset's partition of this epoch's sample space. diff --git a/simulation/core/sim_time.py b/simulation/core/sim_time.py index 5a7f2c042..0531b027e 100644 --- a/simulation/core/sim_time.py +++ b/simulation/core/sim_time.py @@ -39,61 +39,14 @@ class TimeUnit(Enum): class Time(Generic[TValue]): - """Time represents static durations of training time in terms of a :class:`TimeUnit` enum. + """Time represents static durations of training time in terms of a `TimeUnit` enum. - See the :doc:`Time Guide ` for more details on tracking time during training. - - To construct an instance of :class:`Time`, you can either: - - #. Use a value followed by a :class:`TimeUnit` enum or string. For example, - - >>> Time(5, TimeUnit.EPOCH) # describes 5 epochs. - Time(5, TimeUnit.EPOCH) - >>> Time(30_000, "tok") # describes 30,000 tokens. - Time(30000, TimeUnit.TOKEN) - >>> Time(0.5, "dur") # describes 50% of the training process. - Time(0.5, TimeUnit.DURATION) - - #. Use one of the helper methods. See: - - - :meth:`Time.from_epoch` - - :meth:`Time.from_batch` - - :meth:`Time.from_sample` - - :meth:`Time.from_token` - - :meth:`Time.from_duration` - - :meth:`Time.from_timestring`. - - :class:`Time` supports addition and subtraction with other :class:`Time` instances that share the same - :class:`TimeUnit`. For example: - - >>> Time(1, TimeUnit.EPOCH) + Time(2, TimeUnit.EPOCH) - Time(3, TimeUnit.EPOCH) - - :class:`Time` supports multiplication. The multiplier must be either a number or have units of - :attr:`TimeUnit.DURATION`. The multiplicand is scaled, and its units are kept. - - >>> Time(2, TimeUnit.EPOCH) * 0.5 - Time(1, TimeUnit.EPOCH) - - >>> Time(2, TimeUnit.EPOCH) * Time(0.5, TimeUnit.DURATION) - Time(1, TimeUnit.EPOCH) - - - :class:`Time` supports division. If the divisor is an instance of :class:`Time`, then it - must have the same units as the dividend, and the result has units of :attr:`TimeUnit.DURATION`. - For example: - - >>> Time(4, TimeUnit.EPOCH) / Time(2, TimeUnit.EPOCH) - Time(2.0, TimeUnit.DURATION) - - If the divisor is number, then the dividend is scaled, and it keeps its units. For example: - - >>> Time(4, TimeUnit.EPOCH) / 2 - Time(2, TimeUnit.EPOCH) + This is identical to the `Time` class in MosaicML Composer. See the Composer docs for more + details on tracking time during training. Args: value (int | float): The amount of time. - unit (str | TimeUnit): The :class:`TimeUnit` for ``value``. + unit (str | TimeUnit): The `TimeUnit` for ``value``. """ def __init__( diff --git a/simulation/interfaces/sim_script.py b/simulation/interfaces/sim_script.py index 7fd62d234..80aa72a36 100644 --- a/simulation/interfaces/sim_script.py +++ b/simulation/interfaces/sim_script.py @@ -45,7 +45,7 @@ physical_nodes = 2 # number of physical nodes devices = 8 # number of devices per node time_per_sample = 0.0175 # time to process one sample on one device (seconds) -node_internet_bandwidth = 2e9 # network internet per node (bytes/s) +node_internet_bandwidth = 1e7 # network internet per node (bytes/s) # ---------------------------------------------- # diff --git a/simulation/requirements.txt b/simulation/requirements.txt deleted file mode 100644 index ef17a6e60..000000000 --- a/simulation/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -altair==5.1.1 -matplotlib==3.7.2 -mosaicml_streaming==0.6.0 -numpy==1.24.4 -omegaconf==2.3.0 -pandas==2.0.3 -PyYAML==6.0 -sortedcollections==2.1.0 -streamlit==1.26.0 -wandb==0.15.5 diff --git a/simulation/testing/wandb_testing.py b/simulation/testing/wandb_testing.py index 61ea1a328..e863f9ed3 100644 --- a/simulation/testing/wandb_testing.py +++ b/simulation/testing/wandb_testing.py @@ -8,6 +8,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import logging import os import matplotlib.pyplot as plt @@ -22,6 +23,9 @@ from streaming.base import Stream +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + api = wandb.Api() project_id = 'mosaic-ml/streaming-shuffling-algo' @@ -68,7 +72,7 @@ def get_similarity_percentage(real: NDArray, sim: NDArray) -> float: config = run.config if '_step' not in summary: - print('skipping unsuccessful run') + logger.warning(' Skipping unsuccessful run.') continue # get parameters from run config and summary @@ -171,7 +175,7 @@ def get_similarity_percentage(real: NDArray, sim: NDArray) -> float: physical_nodes * (merged_network_use['system.network.recv'].to_numpy()), (merged_network_use['sim_downloads'].to_numpy())) - # print params and results to easily paste to spreadsheet + # log params and results to easily paste to spreadsheet print(run.name, seed, canonical_nodes, physical_nodes, predownload, shuffle_algo, shuffle_block_size, cache_limit, max_duration_value, throughput_similarity, network_similarity) From 76aeaa7c0362a947782d903f5affaa99ef238311 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Fri, 27 Oct 2023 16:30:58 -0700 Subject: [PATCH 29/31] addressed comments, fixed assert statements --- simulation/core/sim_time.py | 4 +- simulation/core/yaml_processing.py | 18 ++-- simulation/interfaces/sim_cli.py | 4 +- simulation/interfaces/sim_script.py | 4 +- simulation/interfaces/sim_ui.py | 9 +- simulation/interfaces/widgets.py | 131 ++++++++++++++-------------- simulation/testing/wandb_testing.py | 4 +- 7 files changed, 96 insertions(+), 78 deletions(-) diff --git a/simulation/core/sim_time.py b/simulation/core/sim_time.py index 0531b027e..c05979ae5 100644 --- a/simulation/core/sim_time.py +++ b/simulation/core/sim_time.py @@ -290,7 +290,9 @@ def from_timestring(cls, timestring: str) -> Time: raise ValueError(f'Invalid time string: {timestring}') match = match[0] match = [x for x in match if x != ''] - assert len(match) == 2, 'each match should have a number followed by the key' + if len(match) != 2: + raise ValueError(f'Each match should have a number followed by the key. Instead, ' + + f'got a match, {match}, of length {len(match)}.') value = match[0] unit = TimeUnit(match[1]) value = float(value) # always parsing first as float b/c it could be scientific notation diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index 24063e8f2..ea571d31a 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -53,7 +53,8 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, om.resolve(config) - assert isinstance(config, DictConfig), 'config must be a dict.' + if not isinstance(config, DictConfig): + raise TypeError(f'`config` must be of type DictConfig. Got type {type(config)}.') # get global batch size if 'global_train_batch_size' in config: @@ -107,9 +108,14 @@ def ingest_yaml(yaml_dict: Optional[dict] = None, if isinstance(train_dataset, DictConfig): train_dataset = om.to_container(train_dataset) - assert isinstance(workers, int), 'workers must be an integer.' - assert isinstance(global_batch_size, int), 'global_batch_size must be an integer.' - assert isinstance(train_dataset, dict), 'train_dataset must be a dict.' + if not isinstance(workers, int): + raise ValueError(f'`workers` must be an int. Instead, got {type(workers)}.') + if not isinstance(global_batch_size, int): + raise ValueError(f'`global_batch_size` must be an int. Instead, got ' + + f'{type(global_batch_size)}.') + if not isinstance(train_dataset, dict): + raise ValueError(f'`train_dataset` must be a dict. Instead, got ' + + f'{type(train_dataset)}.') return total_devices, workers, max_duration, global_batch_size, train_dataset @@ -147,7 +153,9 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat streams_dict = train_dataset.get('streams', None) if streams_dict is not None: streams = [] - assert isinstance(streams_dict, dict), 'streams must be a dict if not a list.' + if not isinstance(streams_dict, dict): + raise TypeError(f'`streams` must be of type dict, if not a list. ' + + f'Got type {type(streams_dict)}.') for stream in streams_dict.values(): if 'path' in stream: del stream['path'] diff --git a/simulation/interfaces/sim_cli.py b/simulation/interfaces/sim_cli.py index 6931afa4e..c7606b1af 100644 --- a/simulation/interfaces/sim_cli.py +++ b/simulation/interfaces/sim_cli.py @@ -82,7 +82,9 @@ # Simulate Run results = next(simulate(dataset, time_per_sample, node_network_bandwidth, max_duration)) - assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' + if len(results) != 4: + raise ValueError(f'Simulation with generate=False should return 4 final results. ' + + f'Instead, received `results` of length {len(results)}.') step_times, step_downloads, startup_time, min_cache_limit = results print('Simulation Finished.') diff --git a/simulation/interfaces/sim_script.py b/simulation/interfaces/sim_script.py index 80aa72a36..4d7b0596b 100644 --- a/simulation/interfaces/sim_script.py +++ b/simulation/interfaces/sim_script.py @@ -78,7 +78,9 @@ node_network_bandwidth=node_internet_bandwidth, max_duration=max_duration)) -assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' +if len(results) != 4: + raise ValueError(f'Simulation with generate=False should return 4 final results. ' + + f'Instead, received `results` of length {len(results)}.') step_times, step_downloads, startup_time, min_cache_limit = results global_batch_size = device_batch_size * devices * physical_nodes diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py index f6cd8ed98..ff68d710a 100644 --- a/simulation/interfaces/sim_ui.py +++ b/simulation/interfaces/sim_ui.py @@ -78,7 +78,7 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp # Initialize min_cache_limit to be 0. Will be replaced by the simulated value. min_cache_limit = 0 # Define partial function to pass to executor map for simulation. - with ProcessPoolExecutor(max_workers=8) as executor: + with ProcessPoolExecutor() as executor: # Submit shuffle quality job to executor. if shuffle_quality: col1.write('Starting shuffle quality analysis...') @@ -92,7 +92,7 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp shuffle_block_size = number_abbrev_to_int(input_params['shuffle_block_size']) samples_per_shard = dataset.get_avg_samples_per_shard() epoch_size = dataset.get_epoch_size() - if epoch_size > 100000000: + if epoch_size > 100_000_000: st.warning('Epoch size is over 100 million samples. Shuffle quality analysis \ will be conducted only on the first 100 million samples.', icon='⚠️') @@ -113,8 +113,9 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp step = total_batches - 1 time_to_first_batch, min_cache_limit = output else: - assert len(output) == 3, 'Simulation with generate=True should return 3 results \ - per step.' + if len(output) != 3: + raise ValueError(f'Simulation with generate=True should return 3 results per' + + f' step. Instead, output was length {len(output)}.') step, step_time, shard_download = output gen_step_times.append(step_time) diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index 7e9d1070f..464fd54a7 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -175,84 +175,84 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = defaults (dict): Dictionary of default values for input params. Defaults to empty dict, {}. """ # split the input column component into left, middle, and right sub columns. - colL, colM, colR = component.columns(3) + col_l, col_m, col_r = component.columns(3) # dataset streams = {} - colL.write('**Dataset Parameters**') + col_l.write('**Dataset Parameters**') if 'streams' in defaults: key = 0 for stream in defaults['streams'].values(): # Case is only possible when reading in streams from yaml file. Stream will have path. - stream_entry(colL, streams, key, add_stream=False, defaults=stream) + stream_entry(col_l, streams, key, add_stream=False, defaults=stream) key += 1 streams = defaults['streams'] else: - stream_entry(colL, streams, 0, add_stream=True) - colL.text('') + stream_entry(col_l, streams, 0, add_stream=True) + col_l.text('') input_params['streams'] = streams # training - colM.write('**Training Parameters**') + col_m.write('**Training Parameters**') if 'max_duration' in defaults: default_max_duration = defaults['max_duration'] default_value = int(default_max_duration.value) default_unit_index = 0 if default_max_duration.unit == TimeUnit.BATCH else 1 - time_value = colM.number_input('training duration', - step=1, - value=default_value, - help='training duration value, in specified units.') - time_units = colM.selectbox('units', ['batches', 'epochs'], - index=default_unit_index, - help='units of training duration.') + time_value = col_m.number_input('training duration', + step=1, + value=default_value, + help='training duration value, in specified units.') + time_units = col_m.selectbox('units', ['batches', 'epochs'], + index=default_unit_index, + help='units of training duration.') else: - time_value = colM.number_input('training duration', - step=1, - value=1000, - help='training duration value, in specified units.') - time_units = colM.selectbox('units', ['batches', 'epochs'], - help='units of training duration.') + time_value = col_m.number_input('training duration', + step=1, + value=1000, + help='training duration value, in specified units.') + time_units = col_m.selectbox('units', ['batches', 'epochs'], + help='units of training duration.') # Get Time object from inputs time_string = str(time_value) time_string += 'ba' if time_units == 'batches' else 'ep' max_duration = ensure_time(time_string, TimeUnit.EPOCH) - epoch_size = colM.text_input('epoch size (samples)', - value='', - help='epoch size for this run, in samples.') + epoch_size = col_m.text_input('epoch size (samples)', + value='', + help='epoch size for this run, in samples.') epoch_size = None if epoch_size == '' or epoch_size == 'None' else int(epoch_size) - device_batch_size = colM.number_input( + device_batch_size = col_m.number_input( 'device batch size', step=1, value=16 if 'device_batch_size' not in defaults else defaults['device_batch_size'], help='number of samples per device (GPU) per batch. \ the global batch size is `device_batch_size * \ devices_per_node * physical_nodes`') - colM.text('') + col_m.text('') input_params['max_duration'] = max_duration input_params['epoch_size'] = epoch_size input_params['device_batch_size'] = device_batch_size # hardware and network - colM.write('**Hardware and Network Parameters**') - physical_nodes = colM.number_input( + col_m.write('**Hardware and Network Parameters**') + physical_nodes = col_m.number_input( 'number of physical nodes', step=1, value=1 if 'physical_nodes' not in defaults else defaults['physical_nodes'], help='number of physical nodes for this run. \ a node typically consists of 8 devices (GPUs).') - devices = colM.number_input('devices per node', - step=1, - value=8 if 'devices' not in defaults else defaults['devices'], - help='number of devices (GPUs) per node for this run. \ + devices = col_m.number_input('devices per node', + step=1, + value=8 if 'devices' not in defaults else defaults['devices'], + help='number of devices (GPUs) per node for this run. \ there are typically 8 devices per node.') - time_per_sample = colM.number_input( + time_per_sample = col_m.number_input( 'process time per sample (s)', step=0.0005, value=0.0175 if 'time_per_sample' not in defaults else defaults['time_per_sample'], format='%.4f', help='time for one device to process one \ sample from your dataset.') - node_network_bandwidth = colM.text_input( + node_network_bandwidth = col_m.text_input( 'network bandwidth per node (bytes/s)', value='500MB' if 'node_network_bandwidth' not in defaults else defaults['node_network_bandwidth'], @@ -260,35 +260,35 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = each node. in practice, network bandwidth is \ variable and is affected by many factors, \ including cluster demand.') - colM.text('') + col_m.text('') input_params['physical_nodes'] = physical_nodes input_params['devices'] = devices input_params['time_per_sample'] = time_per_sample input_params['node_network_bandwidth'] = node_network_bandwidth # streaming - colR.write('**Streaming Parameters**') - workers = colR.number_input('workers per device', - step=1, - value=8 if 'workers' not in defaults else defaults['workers'], - help='number of dataloader workers per device (GPU).') - canonical_nodes = colR.number_input( + col_r.write('**Streaming Parameters**') + workers = col_r.number_input('workers per device', + step=1, + value=8 if 'workers' not in defaults else defaults['workers'], + help='number of dataloader workers per device (GPU).') + canonical_nodes = col_r.number_input( 'number of canonical nodes', step=1, value=2 if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], help='number of canonical nodes to split your dataset \ into. a canonical node is a bucket of shards that is \ assigned to a particular physical node.') - predownload = colR.text_input( + predownload = col_r.text_input( 'predownload per worker (samples)', value='None' if 'predownload' not in defaults else defaults['predownload'], help='number of samples ahead each worker should download. \ predownload does not occur before the first batch; \ rather, it occurs while training is ongoing.') predownload = None if predownload == '' or predownload == 'None' else int(predownload) - shuffle = colR.checkbox(label='shuffle', - value=True if 'shuffle' not in defaults else defaults['shuffle'], - help='whether or not to shuffle the samples for this run.') + shuffle = col_r.checkbox(label='shuffle', + value=True if 'shuffle' not in defaults else defaults['shuffle'], + help='whether or not to shuffle the samples for this run.') shuffle_algo='py1e' if len(defaults) == 0 or 'shuffle_algo' not in defaults \ else defaults['shuffle_algo'] shuffle_block_size='1M' if len(defaults) == 0 or 'shuffle_block_size' not in defaults \ @@ -299,36 +299,37 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = default_index = 0 if 'shuffle_algo' in defaults: default_index = algos.index(defaults['shuffle_algo']) - shuffle_algo = colR.selectbox('shuffling algorithm', - algos, - index=default_index, - help='shuffling algorithm to use for this run. your shuffle \ + shuffle_algo = col_r.selectbox( + 'shuffling algorithm', + algos, + index=default_index, + help='shuffling algorithm to use for this run. your shuffle \ parameters may affect model training.') - shuffle_block_size = colR.text_input( + shuffle_block_size = col_r.text_input( 'shuffle block size (samples)', value='200k' if 'shuffle_block_size' not in defaults else defaults['shuffle_block_size'], help='shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` \ shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly \ shuffled inside each bucket of shards (aka canonical node).') - seed = colR.number_input('shuffle seed', - step=1, - value=42 if 'seed' not in defaults else defaults['seed'], - help='random seed for shuffling.') - cache_limit = colR.text_input( + seed = col_r.number_input('shuffle seed', + step=1, + value=42 if 'seed' not in defaults else defaults['seed'], + help='random seed for shuffling.') + cache_limit = col_r.text_input( 'cache limit (bytes)', value='None' if 'cache_limit' not in defaults else defaults['cache_limit'], help='cache limit per node for this run. \ setting cache limit too low will impact throughput.') cache_limit = None if cache_limit == '' or cache_limit == 'None' else bytes_to_int(cache_limit) sampling_methods = ['balanced', 'fixed'] - sampling_method = colR.selectbox('sampling method', - sampling_methods, - index=0 if 'sampling_method' not in defaults else - sampling_methods.index(defaults['sampling_method']), - help="sampling method for this run. controls how samples are\ + sampling_method = col_r.selectbox('sampling method', + sampling_methods, + index=0 if 'sampling_method' not in defaults else + sampling_methods.index(defaults['sampling_method']), + help="sampling method for this run. controls how samples are\ chosen each epoch. can be either 'balanced' or 'fixed'.") - sampling_granularity = colR.number_input( + sampling_granularity = col_r.number_input( 'sampling granularity', step=1, value=1 if 'sampling_granularity' not in defaults else defaults['sampling_granularity'], @@ -336,13 +337,13 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = samples are balanced across shards. higher values will\ cause more samples to be drawn from each shard at a time.') batching_methods = ['random', 'per_stream', 'stratified'] - batching_method = colR.selectbox('batching method', - batching_methods, - index=0 if 'batching_method' not in defaults else - batching_methods.index(defaults['batching_method']), - help='batching method for this run. controls how batches\ + batching_method = col_r.selectbox('batching method', + batching_methods, + index=0 if 'batching_method' not in defaults else + batching_methods.index(defaults['batching_method']), + help='batching method for this run. controls how batches\ are constructed.') - colR.text('') + col_r.text('') input_params['workers'] = workers input_params['canonical_nodes'] = canonical_nodes input_params['predownload'] = predownload diff --git a/simulation/testing/wandb_testing.py b/simulation/testing/wandb_testing.py index e863f9ed3..3944be3f9 100644 --- a/simulation/testing/wandb_testing.py +++ b/simulation/testing/wandb_testing.py @@ -138,7 +138,9 @@ def get_similarity_percentage(real: NDArray, sim: NDArray) -> float: node_network_bandwidth=node_network_bandwidth, max_duration=max_duration)) - assert len(results) == 4, 'Simulation with generate=False should return 4 final results.' + if len(results) != 4: + raise ValueError(f'Simulation with generate=False should return 4 final results. ' + + f'Instead, received `results` of length {len(results)}.') step_times, step_downloads, startup_time, min_cache_limit = results immediate_batch_throughput = 1 / step_times From f67b815cdb1ba3639c33ad6a27c4cdff14eee0d1 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Fri, 27 Oct 2023 17:44:49 -0700 Subject: [PATCH 30/31] changed to current defaults --- simulation/core/sim_dataset.py | 74 ++++++++++++++++++------ simulation/core/yaml_processing.py | 6 +- simulation/interfaces/interface_utils.py | 3 +- simulation/interfaces/sim_ui.py | 6 +- simulation/interfaces/widgets.py | 26 ++++++--- simulation/launcher.py | 6 +- 6 files changed, 90 insertions(+), 31 deletions(-) diff --git a/simulation/core/sim_dataset.py b/simulation/core/sim_dataset.py index 36b37173e..3be35fd1a 100644 --- a/simulation/core/sim_dataset.py +++ b/simulation/core/sim_dataset.py @@ -61,29 +61,36 @@ class SimulationDataset(StreamingDataset): of current sample. Workers will attempt to download ahead by this many samples during, but not before, training. Recommendation is to provide a value greater than per device batch size to ensure at-least per device batch size number of samples cached locally. - If ``None``, its value gets derived using per device batch size and number of - canonical nodes ``max(batch_size, 256 * batch_size // num_canonical_nodes)``. - Defaults to ``None``. + If ``None``, its value is set to ``8 * batch_size``. Defaults to ``None``. cache_limit (Union[int, str], optional): Maximum size in bytes of this StreamingDataset's shard cache. Before downloading a shard, the least recently used resident shard(s) may be evicted (deleted from the local cache) in order to stay under the limit. Set to ``None`` to disable shard eviction. Supports integer bytes as well as string human-readable bytes (e.g., ``100b``, ``64kb``, ``77mb``, and so on). Defaults to ``None``. - partition_algo (str): Which partitioning algorithm to use. Defaults to ``orig``. + partition_algo (str): Which partitioning algorithm to use. Defaults to ``relaxed``. num_canonical_nodes (int, optional): Canonical number of nodes for shuffling with resumption. The sample space is divided evenly according to the number of canonical nodes. The higher the value, the more independent non-overlapping paths the StreamingDataset replicas take through the shards per model replica (increasing data - source diversity). Defaults to ``None``, which is interpreted as 64 times the number - of nodes of the initial run. + source diversity). If ``None``, this is interpreted as 64 times the number of physical + nodes of the initial run if ``shuffle_algo`` is ``py1s`` or ``py2s``, and simply the + number of physical nodes of the initial run otherwise. Defaults to ``None``. + + .. note:: + + For sequential sample ordering, set ``shuffle`` to ``False`` and + ``num_canonical_nodes`` to the number of physical nodes of the initial run. batch_size (int, optional): Batch size of its DataLoader, which affects how the dataset is partitioned over the workers. Defaults to ``None``. shuffle (bool): Whether to iterate over the samples in randomized order. Defaults to ``False``. - shuffle_algo (str): Which shuffling algorithm to use. Defaults to ``py1s``. + shuffle_algo (str): Which shuffling algorithm to use. Defaults to ``py1e``. shuffle_seed (int): Seed for Deterministic data shuffling. Defaults to ``9176``. - shuffle_block_size (int): Unit of shuffle. Defaults to ``1 << 18``. + shuffle_block_size (int, optional): Unit of shuffle. A canonical node's samples are split + into blocks of this size, and samples within each block are shuffled. If ``None``, its + value is calculated as ``max(4_000_000 // num_canonical_nodes), 1 << 18)``. Defaults to + ``None``. sampling_method (str): Which sampling method to use, either ``balanced`` or ``fixed``. Defaults to ``balanced``. sampling_granularity (int): When picking samples for a stream's final partial repeat, @@ -109,13 +116,13 @@ def __init__(self, epoch_size: Optional[Union[int, str]] = None, predownload: Optional[int] = None, cache_limit: Optional[Union[int, str]] = None, - partition_algo: str = 'orig', + partition_algo: str = 'relaxed', num_canonical_nodes: Optional[int] = None, batch_size: Optional[int] = None, shuffle: bool = False, - shuffle_algo: str = 'py1s', + shuffle_algo: str = 'py1e', shuffle_seed: int = 9176, - shuffle_block_size: int = 1 << 18, + shuffle_block_size: Optional[int] = None, sampling_method: str = 'balanced', sampling_granularity: int = 1, batching_method: str = 'random') -> None: @@ -129,10 +136,8 @@ def __init__(self, self.workers = workers self.cache_limit = cache_limit self.partition_algo = partition_algo - self.num_canonical_nodes = num_canonical_nodes or 64 * nodes - self.batch_size = batch_size or 1 - self.predownload = predownload if predownload is not None \ - else max(self.batch_size, 256 * self.batch_size // self.num_canonical_nodes) + self.predownload = predownload + self.batch_size = batch_size self.shuffle = shuffle self.shuffle_algo = shuffle_algo self.shuffle_seed = shuffle_seed @@ -140,6 +145,20 @@ def __init__(self, self.sampling_method = sampling_method self.sampling_granularity = sampling_granularity self.batching_method = batching_method + self.num_canonical_nodes = num_canonical_nodes + + self.initial_physical_nodes = nodes + + # Set num_canonical_nodes based on the shuffling algorithm chosen. + if self.num_canonical_nodes is None: + if self.shuffle_algo in ['py1s', 'py2s']: + self.num_canonical_nodes = 64 * self.nodes + else: + self.num_canonical_nodes = self.nodes + + # Set shuffle_block_size if not provided, based on num_canonical_nodes. + if self.shuffle_block_size is None: + self.shuffle_block_size = max(4_000_000 // self.num_canonical_nodes, 1 << 18) # Check streams vs remote/local. if bool(streams) == (bool(remote) or bool(local)): @@ -158,11 +177,17 @@ def __init__(self, f'Invalid batching method: {batching_method}. Must be one of `random`, \ `per_stream`, or `stratified`.') - # Check that predownload is at least per device batch size. - if self.predownload < self.batch_size: + # Check that predownload is at least per device batch size, and set it if currently `None`. + if self.predownload is not None and self.batch_size is not None and \ + self.predownload < self.batch_size: warnings.warn(f'predownload < batch_size ({self.predownload} < {self.batch_size}).' + f'This may result in slower batch time. Recommendation is to set ' + f'predownload to at-least batch_size.') + elif self.predownload is None: + self.predownload = 8 * self.batch_size if self.batch_size is not None else 64 + + self.batch_size = batch_size or 1 + # Convert epoch size from string to int, if needed. Cannot be negative. epoch_size_value = None if epoch_size: @@ -391,6 +416,9 @@ def get_num_canonical_nodes(self) -> int: Returns: int: The dataset's number of canonical nodes. """ + if not isinstance(self.num_canonical_nodes, int): + raise TypeError(f'`self.num_canonical_nodes` must be an int. ' + + f'Got {type(self.num_canonical_nodes)} instead.') return self.num_canonical_nodes def get_batch_size(self) -> int: @@ -399,6 +427,9 @@ def get_batch_size(self) -> int: Returns: int: The dataset's batch size. """ + if not isinstance(self.batch_size, int): + raise TypeError(f'`self.batch_size` must be an int. ' + + f'Got {type(self.batch_size)} instead.') return self.batch_size def get_num_shards(self) -> int: @@ -423,6 +454,9 @@ def get_predownload(self) -> int: Returns: int: The dataset's predownload. """ + if not isinstance(self.predownload, int): + raise TypeError(f'`self.predownload` must be an int. ' + + f'Got {type(self.predownload)} instead.') return self.predownload def get_cache_limit(self) -> Optional[int]: @@ -449,6 +483,9 @@ def get_num_batches(self) -> int: Returns: int: The dataset's number of batches. """ + if self.batch_size is None: + raise ValueError(f'Cannot get number of batches without `batch size`, had ' + + f'`batch_size` of `None`') return self.epoch_size // (self.batch_size * self.devices * self.nodes) def get_stream_info(self) -> dict: @@ -489,6 +526,9 @@ def get_shuffle_block_size(self) -> int: Returns: int: The dataset's shuffle block size. """ + if not isinstance(self.shuffle_block_size, int): + raise TypeError(f'`self.shuffle_block_size` must be an int. ' + + f'Got {type(self.shuffle_block_size)} instead.') return self.shuffle_block_size def get_epoch_size(self) -> int: diff --git a/simulation/core/yaml_processing.py b/simulation/core/yaml_processing.py index ea571d31a..b90c68832 100644 --- a/simulation/core/yaml_processing.py +++ b/simulation/core/yaml_processing.py @@ -172,15 +172,15 @@ def create_simulation_dataset(nodes: int, devices: int, workers: int, global_bat epoch_size = train_dataset.get('epoch_size', None) predownload = train_dataset.get('predownload', None) cache_limit = train_dataset.get('cache_limit', None) - partition_algo = train_dataset.get('partition_algo', 'orig') + partition_algo = train_dataset.get('partition_algo', 'relaxed') num_canonical_nodes = train_dataset.get('num_canonical_nodes', None) if global_batch_size % (devices * nodes) != 0: raise ValueError('global_batch_size must be divisible by total number of devices.') batch_size = global_batch_size // (devices * nodes) shuffle = train_dataset.get('shuffle', False) - shuffle_algo = train_dataset.get('shuffle_algo', 'py1s') + shuffle_algo = train_dataset.get('shuffle_algo', 'py1e') shuffle_seed = train_dataset.get('shuffle_seed', 9176) - shuffle_block_size = train_dataset.get('shuffle_block_size', 1 << 18) + shuffle_block_size = train_dataset.get('shuffle_block_size', None) sampling_method = train_dataset.get('sampling_method', 'balanced') sampling_granularity = train_dataset.get('sampling_granularity', 1) batching_method = train_dataset.get('batching_method', 'random') diff --git a/simulation/interfaces/interface_utils.py b/simulation/interfaces/interface_utils.py index b145aa32c..863ced6d6 100644 --- a/simulation/interfaces/interface_utils.py +++ b/simulation/interfaces/interface_utils.py @@ -89,7 +89,8 @@ def get_train_dataset_params(input_params: dict, old_params: Optional[dict] = No train_dataset_params['shuffle'] = input_params['shuffle'] train_dataset_params['shuffle_algo'] = input_params['shuffle_algo'] train_dataset_params['shuffle_block_size'] = number_abbrev_to_int( - input_params['shuffle_block_size']) + input_params['shuffle_block_size']) if input_params['shuffle_block_size'] is not None \ + else None train_dataset_params['shuffle_seed'] = input_params['seed'] train_dataset_params['sampling_method'] = input_params['sampling_method'] train_dataset_params['sampling_granularity'] = input_params['sampling_granularity'] diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py index ff68d710a..2697b7d70 100644 --- a/simulation/interfaces/sim_ui.py +++ b/simulation/interfaces/sim_ui.py @@ -85,11 +85,15 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp input_params = st.session_state['input_params'] # Use multiprocessing to get the shuffle quality results. canonical_nodes = input_params['canonical_nodes'] + if canonical_nodes is None: + canonical_nodes = dataset.get_num_canonical_nodes() physical_nodes = input_params['physical_nodes'] devices = input_params['devices'] workers = input_params['workers'] device_batch_size = input_params['device_batch_size'] - shuffle_block_size = number_abbrev_to_int(input_params['shuffle_block_size']) + shuffle_block_size = number_abbrev_to_int(input_params['shuffle_block_size']) \ + if input_params['shuffle_block_size'] is not None \ + else dataset.get_shuffle_block_size() samples_per_shard = dataset.get_avg_samples_per_shard() epoch_size = dataset.get_epoch_size() if epoch_size > 100_000_000: diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index 464fd54a7..084a8a8ff 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -108,18 +108,18 @@ def stream_entry(component: DeltaGenerator, else: shards = component.number_input('number of shards', step=1, - value=20850, + value=2000, help='number of \ total shards across your whole dataset.', key=str(key) + 'shards') samples_per_shard = component.number_input('samples per shard', step=1, - value=4093, + value=4000, help='average number of samples contained \ in each shard.', key=str(key) + 'samples') avg_raw_shard_size = component.text_input('avg raw shard size (bytes)', - value='67MB', + value='60MB', help='average raw size, in bytes, \ of a single shard.', key=str(key) + 'rawsize') @@ -272,13 +272,21 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = step=1, value=8 if 'workers' not in defaults else defaults['workers'], help='number of dataloader workers per device (GPU).') - canonical_nodes = col_r.number_input( + # canonical_nodes = col_r.number_input( + # 'number of canonical nodes', + # step=1, + # value=2 if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], + # help='number of canonical nodes to split your dataset \ + # into. a canonical node is a bucket of shards that is \ + # assigned to a particular physical node.') + canonical_nodes = col_r.text_input( 'number of canonical nodes', - step=1, - value=2 if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], + value='None' if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], help='number of canonical nodes to split your dataset \ into. a canonical node is a bucket of shards that is \ assigned to a particular physical node.') + canonical_nodes = None if canonical_nodes == '' or canonical_nodes == 'None' \ + else int(canonical_nodes) predownload = col_r.text_input( 'predownload per worker (samples)', value='None' if 'predownload' not in defaults else defaults['predownload'], @@ -291,7 +299,7 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = help='whether or not to shuffle the samples for this run.') shuffle_algo='py1e' if len(defaults) == 0 or 'shuffle_algo' not in defaults \ else defaults['shuffle_algo'] - shuffle_block_size='1M' if len(defaults) == 0 or 'shuffle_block_size' not in defaults \ + shuffle_block_size=None if len(defaults) == 0 or 'shuffle_block_size' not in defaults \ else defaults['shuffle_block_size'] seed = 42 if len(defaults) == 0 or 'seed' not in defaults else defaults['seed'] if shuffle: @@ -307,7 +315,7 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = parameters may affect model training.') shuffle_block_size = col_r.text_input( 'shuffle block size (samples)', - value='200k' + value='None' if 'shuffle_block_size' not in defaults else defaults['shuffle_block_size'], help='shuffle block size for this run. used in the `py1b`, `py1br`, and `py1e` \ shuffling algorithms, samples in blocks of `shuffle_block_size` are randomly \ @@ -316,6 +324,8 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = step=1, value=42 if 'seed' not in defaults else defaults['seed'], help='random seed for shuffling.') + if shuffle_block_size == '' or shuffle_block_size == 'None': + shuffle_block_size = None cache_limit = col_r.text_input( 'cache limit (bytes)', value='None' if 'cache_limit' not in defaults else defaults['cache_limit'], diff --git a/simulation/launcher.py b/simulation/launcher.py index 6fea1f80e..2109a20f4 100644 --- a/simulation/launcher.py +++ b/simulation/launcher.py @@ -9,4 +9,8 @@ def launch_simulation_ui(): """Launch the simulation UI.""" - subprocess.run(['streamlit', 'run', os.path.abspath('simulation/interfaces/sim_ui.py')]) + absolute_cwd_path = os.path.dirname(os.path.abspath(__file__)) + sim_file_relative_path = 'interfaces/sim_ui.py' + absolute_sim_path = os.path.join(absolute_cwd_path, sim_file_relative_path) + print(absolute_sim_path) + subprocess.run(['streamlit', 'run', absolute_sim_path]) From 524675f6f3cbaa524c95344ed9cbb14a5b8689e6 Mon Sep 17 00:00:00 2001 From: Saaketh Date: Mon, 30 Oct 2023 09:37:58 -0700 Subject: [PATCH 31/31] minor usability improvements --- simulation/interfaces/sim_ui.py | 13 ++++++++++--- simulation/interfaces/widgets.py | 15 +++++---------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/simulation/interfaces/sim_ui.py b/simulation/interfaces/sim_ui.py index 2697b7d70..77848dd79 100644 --- a/simulation/interfaces/sim_ui.py +++ b/simulation/interfaces/sim_ui.py @@ -8,6 +8,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..')) +import math from concurrent.futures import ProcessPoolExecutor from io import StringIO from typing import Union @@ -137,7 +138,7 @@ def submit_jobs(shuffle_quality: bool, dataset: SimulationDataset, time_per_samp steps.append(step + 1) # update plots and percentages at regular intervals - plot_interval = (total_batches) // 15 + plot_interval = math.ceil(total_batches / 15) if step == 1 or step % plot_interval == 0 or step == total_batches - 1: rolling_throughput_df = pd.DataFrame({ 'step': steps, @@ -251,7 +252,7 @@ def get_input_params_initial(physical_nodes: int, devices: int, workers: int, physical_nodes = col1.number_input( 'number of physical nodes', step=1, - value=1, + value=total_devices // 8 if total_devices is not None else 1, help= 'number of physical nodes for this run. a node typically consists of 8 devices (GPUs).' ) @@ -278,7 +279,7 @@ def get_input_params_initial(physical_nodes: int, devices: int, workers: int, help='time for one device to process one sample from your dataset.') time_per_sample = float(time_per_sample) node_internet_bandwidth = col1.text_input('network bandwidth per node (bytes/s)', - value='1GB', + value='500MB', help='network bandwidth available to each \ node. in practice, network bandwidth is \ variable and is affected by many factors, \ @@ -344,6 +345,12 @@ def get_input_params_initial(physical_nodes: int, devices: int, workers: int, col1.write('Starting Simulation...') submit_jobs(shuffle_quality, dataset, time_per_sample, node_internet_bandwidth, max_duration) + else: + # In this case, no file is uploaded, and we should clear dataset and input params if needed + if 'input_params' in st.session_state: + del st.session_state['input_params'] + if 'orig_dataset' in st.session_state: + del st.session_state['orig_dataset'] else: submitted = col1.button('Simulate Run', use_container_width=True) col1.text('') diff --git a/simulation/interfaces/widgets.py b/simulation/interfaces/widgets.py index 084a8a8ff..d9959befe 100644 --- a/simulation/interfaces/widgets.py +++ b/simulation/interfaces/widgets.py @@ -104,6 +104,8 @@ def stream_entry(component: DeltaGenerator, path_type = component.selectbox('path type', ['local', 'remote'], key=str(key) + 'path_type') stream_entries['path_type'] = path_type + if path[-1] != '/': + path += '/' stream_entries['path'] = path else: shards = component.number_input('number of shards', @@ -142,21 +144,21 @@ def stream_entry(component: DeltaGenerator, represents.', key=str(key) + 'proportion', disabled=(not add_stream)) - proportion = float(proportion) if proportion != 'None' else None + proportion = None if proportion == 'None' or proportion == '' else float(proportion) repeat = component.text_input('repeat', value='None' if defaults is None else defaults['repeat'], help='number of times to repeat the samples in this \ stream.', key=str(key) + 'repeat', disabled=(not add_stream)) - repeat = float(repeat) if repeat != 'None' else None + repeat = None if repeat == 'None' or repeat == '' else float(repeat) choose = component.text_input('choose', value='None' if defaults is None else defaults['choose'], help='number of samples to choose from this \ stream.', key=str(key) + 'choose', disabled=(not add_stream)) - choose = int(choose) if choose != 'None' else None + choose = None if choose == 'None' or choose == '' else int(choose) stream_entries['proportion'] = proportion stream_entries['repeat'] = repeat stream_entries['choose'] = choose @@ -272,13 +274,6 @@ def param_inputs(component: DeltaGenerator, input_params: dict, defaults: dict = step=1, value=8 if 'workers' not in defaults else defaults['workers'], help='number of dataloader workers per device (GPU).') - # canonical_nodes = col_r.number_input( - # 'number of canonical nodes', - # step=1, - # value=2 if 'canonical_nodes' not in defaults else defaults['canonical_nodes'], - # help='number of canonical nodes to split your dataset \ - # into. a canonical node is a bucket of shards that is \ - # assigned to a particular physical node.') canonical_nodes = col_r.text_input( 'number of canonical nodes', value='None' if 'canonical_nodes' not in defaults else defaults['canonical_nodes'],

|j6TmOvh+90?0sY6>2m~M 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