Files
2026-07-13 13:17:40 +08:00

476 lines
14 KiB
Python

import argparse
import contextlib
import csv
import logging
import os
import random
import subprocess
import tempfile
from typing import Callable, Dict, Iterable, List
import numpy as np
import ray
from ray.experimental.raysort import constants, logging_utils, sortlib, tracing_utils
from ray.experimental.raysort.types import (
BlockInfo,
ByteCount,
PartId,
PartInfo,
Path,
RecordCount,
)
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
Args = argparse.Namespace
# ------------------------------------------------------------
# Parse Arguments
# ------------------------------------------------------------
def get_args(*args, **kwargs):
parser = argparse.ArgumentParser()
parser.add_argument(
"--ray_address",
default="auto",
type=str,
help="if set to None, will launch a local Ray cluster",
)
parser.add_argument(
"--total_data_size",
default=1 * 1000 * 1024 * 1024 * 1024,
type=ByteCount,
help="total data size in bytes",
)
parser.add_argument(
"--num_mappers",
default=256,
type=int,
help="number of map tasks",
)
parser.add_argument(
"--num_mappers_per_round",
default=16,
type=int,
help="number of map tasks per first-stage merge tasks",
)
parser.add_argument(
"--num_reducers",
default=16,
type=int,
help="number of second-stage reduce tasks",
)
parser.add_argument(
"--num_concurrent_rounds",
default=4,
type=int,
help="max number of rounds of map/merge tasks in flight",
)
parser.add_argument(
"--reducer_input_chunk",
default=100 * 1024 * 1024,
type=ByteCount,
help="bytes to read from each file in reduce tasks",
)
parser.add_argument(
"--skip_sorting",
default=False,
action="store_true",
help="if set, no sorting is actually performed",
)
parser.add_argument(
"--skip_input",
default=False,
action="store_true",
help="if set, mappers will not read data from disk",
)
parser.add_argument(
"--skip_output",
default=False,
action="store_true",
help="if set, reducers will not write out results to disk",
)
# Which tasks to run?
tasks_group = parser.add_argument_group(
"tasks to run", "if no task is specified, will run all tasks"
)
tasks = ["generate_input", "sort", "validate_output"]
for task in tasks:
tasks_group.add_argument(f"--{task}", action="store_true")
args = parser.parse_args(*args, **kwargs)
# Derive additional arguments.
args.input_part_size = ByteCount(args.total_data_size / args.num_mappers)
assert args.num_mappers % args.num_mappers_per_round == 0
args.num_rounds = int(args.num_mappers / args.num_mappers_per_round)
args.mount_points = _get_mount_points()
# If no tasks are specified, run all tasks.
args_dict = vars(args)
if not any(args_dict[task] for task in tasks):
for task in tasks:
args_dict[task] = True
return args
def _get_mount_points():
default_ret = [tempfile.gettempdir()]
mnt = "/mnt"
if os.path.exists(mnt):
ret = [os.path.join(mnt, d) for d in os.listdir(mnt)]
if len(ret) > 0:
return ret
return default_ret
# ------------------------------------------------------------
# Generate Input
# ------------------------------------------------------------
def _part_info(args: Args, part_id: PartId, kind="input") -> PartInfo:
node = ray._private.worker.global_worker.node_ip_address
mnt = random.choice(args.mount_points)
filepath = _get_part_path(mnt, part_id, kind)
return PartInfo(part_id, node, filepath)
def _get_part_path(mnt: Path, part_id: PartId, kind="input") -> Path:
assert kind in {"input", "output", "temp"}
dir_fmt = constants.DATA_DIR_FMT[kind]
dirpath = dir_fmt.format(mnt=mnt)
os.makedirs(dirpath, exist_ok=True)
filename_fmt = constants.FILENAME_FMT[kind]
filename = filename_fmt.format(part_id=part_id)
filepath = os.path.join(dirpath, filename)
return filepath
@ray.remote
def generate_part(
args: Args, part_id: PartId, size: RecordCount, offset: RecordCount
) -> PartInfo:
logging_utils.init()
pinfo = _part_info(args, part_id)
subprocess.run(
[constants.GENSORT_PATH, f"-b{offset}", f"{size}", pinfo.path], check=True
)
logging.info(f"Generated input {pinfo}")
return pinfo
def generate_input(args: Args):
if args.skip_input:
return
size = constants.bytes_to_records(args.input_part_size)
offset = 0
tasks = []
for part_id in range(args.num_mappers):
tasks.append(generate_part.remote(args, part_id, size, offset))
offset += size
assert offset == constants.bytes_to_records(args.total_data_size), args
logging.info(f"Generating {len(tasks)} partitions")
parts = ray.get(tasks)
with open(constants.INPUT_MANIFEST_FILE, "w") as fout:
writer = csv.writer(fout)
writer.writerows(parts)
# ------------------------------------------------------------
# Sort
# ------------------------------------------------------------
def _load_manifest(args: Args, path: Path) -> List[PartInfo]:
if args.skip_input:
return [PartInfo(i, None, None) for i in range(args.num_mappers)]
with open(path) as fin:
reader = csv.reader(fin)
return [PartInfo(int(part_id), node, path) for part_id, node, path in reader]
def _load_partition(args: Args, path: Path) -> np.ndarray:
if args.skip_input:
return np.frombuffer(
np.random.bytes(args.input_part_size), dtype=np.uint8
).copy()
return np.fromfile(path, dtype=np.uint8)
def _dummy_sort_and_partition(
part: np.ndarray, boundaries: List[int]
) -> List[BlockInfo]:
N = len(boundaries)
offset = 0
size = int(np.ceil(part.size / N))
blocks = []
for _ in range(N):
blocks.append((offset, size))
offset += size
return blocks
@ray.remote
@tracing_utils.timeit("map")
def mapper(
args: Args, mapper_id: PartId, boundaries: List[int], path: Path
) -> List[np.ndarray]:
logging_utils.init()
part = _load_partition(args, path)
sort_fn = (
_dummy_sort_and_partition if args.skip_sorting else sortlib.sort_and_partition
)
blocks = sort_fn(part, boundaries)
return [part[offset : offset + size] for offset, size in blocks]
def _dummy_merge(
num_blocks: int, _n: int, get_block: Callable[[int, int], np.ndarray]
) -> Iterable[np.ndarray]:
blocks = [((i, 0), get_block(i, 0)) for i in range(num_blocks)]
while len(blocks) > 0:
(m, d), block = blocks.pop(random.randrange(len(blocks)))
yield block
d_ = d + 1
block = get_block(m, d_)
if block is None:
continue
blocks.append(((m, d_), block))
def _merge_impl(
args: Args,
M: int,
pinfo: PartInfo,
get_block: Callable[[int, int], np.ndarray],
skip_output=False,
):
merge_fn = _dummy_merge if args.skip_sorting else sortlib.merge_partitions
merger = merge_fn(M, get_block)
if skip_output:
for datachunk in merger:
del datachunk
else:
with open(pinfo.path, "wb") as fout:
for datachunk in merger:
fout.write(datachunk)
return pinfo
# See worker_placement_groups() for why `num_cpus=0`.
@ray.remote(num_cpus=0, resources={"worker": 1})
@tracing_utils.timeit("merge")
def merge_mapper_blocks(
args: Args, reducer_id: PartId, mapper_id: PartId, *blocks: List[np.ndarray]
) -> PartInfo:
part_id = constants.merge_part_ids(reducer_id, mapper_id)
pinfo = _part_info(args, part_id, kind="temp")
M = len(blocks)
def get_block(i, d):
if i >= M or d > 0:
return None
return blocks[i]
return _merge_impl(args, M, pinfo, get_block)
# See worker_placement_groups() for why `num_cpus=0`.
@ray.remote(num_cpus=0, resources={"worker": 1})
@tracing_utils.timeit("reduce")
def final_merge(
args: Args, reducer_id: PartId, *merged_parts: List[PartInfo]
) -> PartInfo:
M = len(merged_parts)
def _load_block_chunk(pinfo: PartInfo, d: int) -> np.ndarray:
return np.fromfile(
pinfo.path,
dtype=np.uint8,
count=args.reducer_input_chunk,
offset=d * args.reducer_input_chunk,
)
def get_block(i, d):
ret = _load_block_chunk(merged_parts[i], d)
if ret.size == 0:
return None
return ret
pinfo = _part_info(args, reducer_id, "output")
return _merge_impl(args, M, pinfo, get_block, args.skip_output)
def _node_res(node: str) -> Dict[str, float]:
return {"resources": {f"node:{node}": 1e-3}}
@contextlib.contextmanager
def worker_placement_groups(args: Args) -> List[ray.PlacementGroupID]:
"""
Returns one placement group per node with a `worker` resource. To run
tasks in the placement group, use
`@ray.remote(num_cpus=0, resources={"worker": 1})`. Ray does not
automatically reserve CPU resources, so tasks must specify `num_cpus=0`
in order to run in a placement group.
"""
pgs = [ray.util.placement_group([{"worker": 1}]) for _ in range(args.num_reducers)]
ray.get([pg.ready() for pg in pgs])
try:
yield pgs
finally:
for pg in pgs:
ray.util.remove_placement_group(pg)
@tracing_utils.timeit("sort", report_time=True)
def sort_main(args: Args):
parts = _load_manifest(args, constants.INPUT_MANIFEST_FILE)
assert len(parts) == args.num_mappers
boundaries = sortlib.get_boundaries(args.num_reducers)
# The exception of 'ValueError("Resource quantities >1 must be whole numbers.")'
# will be raised if the `num_cpus` > 1 and not an integer.
num_cpus = os.cpu_count() / args.num_concurrent_rounds
if num_cpus > 1.0:
num_cpus = int(num_cpus)
mapper_opt = {
"num_returns": args.num_reducers,
"num_cpus": num_cpus,
} # Load balance across worker nodes by setting `num_cpus`.
merge_results = np.empty((args.num_rounds, args.num_reducers), dtype=object)
part_id = 0
with worker_placement_groups(args) as pgs:
for round in range(args.num_rounds):
# Limit the number of in-flight rounds.
num_extra_rounds = round - args.num_concurrent_rounds + 1
if num_extra_rounds > 0:
ray.wait(
[f for f in merge_results.flatten() if f is not None],
num_returns=num_extra_rounds * args.num_reducers,
)
# Submit map tasks.
mapper_results = np.empty(
(args.num_mappers_per_round, args.num_reducers), dtype=object
)
for _ in range(args.num_mappers_per_round):
_, node, path = parts[part_id]
m = part_id % args.num_mappers_per_round
mapper_results[m, :] = mapper.options(**mapper_opt).remote(
args, part_id, boundaries, path
)
part_id += 1
# Submit merge tasks.
merge_results[round, :] = [
merge_mapper_blocks.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pgs[r]
)
).remote(args, r, round, *mapper_results[:, r].tolist())
for r in range(args.num_reducers)
]
# Delete local references to mapper results.
mapper_results = None
# Submit second-stage reduce tasks.
reducer_results = [
final_merge.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pgs[r]
)
).remote(args, r, *merge_results[:, r].tolist())
for r in range(args.num_reducers)
]
reducer_results = ray.get(reducer_results)
if not args.skip_output:
with open(constants.OUTPUT_MANIFEST_FILE, "w") as fout:
writer = csv.writer(fout)
writer.writerows(reducer_results)
logging.info(ray._private.internal_api.memory_summary(stats_only=True))
# ------------------------------------------------------------
# Validate Output
# ------------------------------------------------------------
def _run_valsort(args: List[str]):
proc = subprocess.run([constants.VALSORT_PATH] + args, capture_output=True)
if proc.returncode != 0:
logging.critical("\n" + proc.stderr.decode("ascii"))
raise RuntimeError(f"Validation failed: {args}")
@ray.remote
def validate_part(path: Path):
logging_utils.init()
sum_path = path + ".sum"
_run_valsort(["-o", sum_path, path])
logging.info(f"Validated output {path}")
with open(sum_path, "rb") as fin:
return os.path.getsize(path), fin.read()
def validate_output(args: Args):
if args.skip_sorting or args.skip_output:
return
partitions = _load_manifest(args, constants.OUTPUT_MANIFEST_FILE)
results = []
for _, node, path in partitions:
results.append(validate_part.options(**_node_res(node)).remote(path))
logging.info(f"Validating {len(results)} partitions")
results = ray.get(results)
total = sum(s for s, _ in results)
assert total == args.total_data_size, total - args.total_data_size
all_checksum = b"".join(c for _, c in results)
with tempfile.NamedTemporaryFile() as fout:
fout.write(all_checksum)
fout.flush()
_run_valsort(["-s", fout.name])
logging.info("All OK!")
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def init(args: Args):
if not args.ray_address:
ray.init(resources={"worker": os.cpu_count()})
else:
ray.init(address=args.ray_address)
logging_utils.init()
logging.info(args)
os.makedirs(constants.WORK_DIR, exist_ok=True)
resources = ray.cluster_resources()
logging.info(resources)
args.num_workers = resources["worker"]
progress_tracker = tracing_utils.create_progress_tracker(args)
return progress_tracker
def main(args: Args):
# Keep the actor handle in scope for the duration of the program.
_progress_tracker = init(args) # noqa F841
if args.generate_input:
generate_input(args)
if args.sort:
sort_main(args)
if args.validate_output:
validate_output(args)
if __name__ == "__main__":
main(get_args())