-
Notifications
You must be signed in to change notification settings - Fork 227
distributed merge of per-rank Megatron data files #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
269af4e
9ba081b
d29a702
ed49713
e94f2a0
687ff32
af59545
4f648a0
9f2ba6a
72d6c9c
8b67bec
3eca1f3
a691b48
e4a34e2
8b168ca
7a02693
eca2940
b14491d
ec11281
354d13b
2dc3f7a
980e904
ebd20a6
69b2f49
50de06a
af290ad
4b58c74
71a2fdc
73d3a24
b9e69be
da615c6
c42f41f
a3a7d53
163310a
01b2be0
4b6e8ff
ea08555
2524fce
ca14d48
d482f36
28d76f5
f706108
f122883
57c012e
eed8327
a75cfc2
afcfcf9
74b733a
dadb51b
a2f8fa0
39e6cd7
2a29d99
d6fa895
1216c0a
a64d3da
fde439e
ba14351
852fdd0
61f4b46
22400f3
5cfcb95
74c4883
373e514
78ab715
002b403
ba763f7
fa11159
7e53fd3
53df36f
c43348f
81c21dd
adee502
13ae421
f3e1b1d
42962e1
9a2f383
15b7603
4adaddd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -417,21 +417,21 @@ def __init__(self, path, skip_warmup=False): | |
| offset = stream.tell() | ||
|
|
||
| if not skip_warmup: | ||
| print_rank_0(" warming up index mmap file...") | ||
| # print_rank_0(" warming up index mmap file...") | ||
| _warmup_mmap_file(path) | ||
|
|
||
| self._bin_buffer_mmap = np.memmap(path, mode='r', order='C') | ||
| self._bin_buffer = memoryview(self._bin_buffer_mmap) | ||
| print_rank_0(" reading sizes...") | ||
| # print_rank_0(" reading sizes...") | ||
| self._sizes = np.frombuffer( | ||
| self._bin_buffer, | ||
| dtype=np.int32, | ||
| count=self._len, | ||
| offset=offset) | ||
| print_rank_0(" reading pointers...") | ||
| # print_rank_0(" reading pointers...") | ||
| self._pointers = np.frombuffer(self._bin_buffer, dtype=np.int64, count=self._len, | ||
| offset=offset + self._sizes.nbytes) | ||
| print_rank_0(" reading document index...") | ||
| # print_rank_0(" reading document index...") | ||
| self._doc_idx = np.frombuffer(self._bin_buffer, dtype=np.int64, count=self._doc_count, | ||
| offset=offset + self._sizes.nbytes + self._pointers.nbytes) | ||
|
|
||
|
|
@@ -576,8 +576,8 @@ def merge_file_(self, another_file): | |
| index = MMapIndexedDataset.Index(index_file_path(another_file)) | ||
| assert index.dtype == self._dtype | ||
|
|
||
| total_len = len(index.sizes)+len(self._sizes) | ||
| print(f" concat {another_file} size={len(index.sizes)} for a total size of {total_len}") | ||
| # total_len = len(index.sizes)+len(self._sizes) | ||
| # print(f" concat {another_file} size={len(index.sizes)} for a total size of {total_len}") | ||
|
|
||
| offset = len(self._sizes) | ||
| self._sizes.extend(index.sizes) | ||
|
|
@@ -592,3 +592,213 @@ def finalize(self, index_file): | |
|
|
||
| with MMapIndexedDataset.Index.writer(index_file, self._dtype) as index: | ||
| index.write(self._sizes, self._doc_idx) | ||
|
|
||
|
|
||
| def mpi_get_sum(val, mpi, comm): | ||
| """Compute sum of val, and return total on all ranks.""" | ||
| insize = np.array([val], dtype=np.int64) | ||
| outsize = np.zeros_like(insize) | ||
| comm.Allreduce(insize, outsize, op=mpi.SUM) | ||
| return outsize[0] | ||
|
|
||
|
|
||
| def mpi_get_offset(val, mpi, comm): | ||
| """Compute preifx sum (exclusive scan) of val, and return offset of each rank.""" | ||
|
adammoody marked this conversation as resolved.
Outdated
|
||
| insize = np.array([val], dtype=np.int64) | ||
| outsize = np.zeros_like(insize) | ||
| comm.Scan(insize, outsize, op=mpi.SUM) | ||
| offset = outsize[0] - insize[0] | ||
| return offset | ||
|
|
||
|
|
||
| def mpi_get_min(val, mpi, comm): | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
| """Return minimum val to all ranks.""" | ||
| insize = np.array([val], dtype=np.int64) | ||
| outsize = np.zeros_like(insize) | ||
| comm.Allreduce(insize, outsize, op=mpi.MIN) | ||
| return outsize[0] | ||
|
|
||
|
|
||
| def mpi_create_file(filename, mpi, comm): | ||
| """Create, truncate, and open a file shared by all ranks.""" | ||
| # Don't truncate file until all ranks reach this point | ||
| comm.barrier() | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Wait for rank 0 to open (and truncate) file, | ||
| # then have all ranks open file for writing. | ||
| rank = comm.Get_rank() | ||
| if rank == 0: | ||
| f = open(filename, 'wb') | ||
| comm.barrier() | ||
| if rank != 0: | ||
| f = open(filename, 'r+b') | ||
|
|
||
| # TODO: verify that all ranks successfully opened the file | ||
| comm.barrier() | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
| return f | ||
|
|
||
|
|
||
| # To create the binary files given a set of per-rank binary | ||
| # files, one simply concatenates the data from the per-rank | ||
| # binary files in rank order. We stat each rank file to determine | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I remember you saying something about stat taking some time before being updated correctly? Am I hallucinating, or do we have potentially a race condition?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It depends on the backing file system being used and also how the procs access the files. On file systems like Lustre and GPFS, the stat info will be up-to-date and consistent to all procs on all nodes. On a file system like NFS, procs on one node may see a different file size than procs on another node due to client-side caching of file metadata. However, even on NFS, the stat info should be up-to-date and correct from the process where the file was written, and the algorithm used here satisfies that. Having said all of that, writing any type of shared file in NFS is risky (not well-supported). I think the stance we should take is to say this is perfectly safe for Lustre/GPFS. It may or may not work on NFS -- users can try, but they do so at their own risk.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hum so I'm not familiar with these notions, I'm guessing JZ is Lustre or GPFS. After reading the doc, it seems to be the case: If so, can you add something at the top of file in order to document that? And maybe someone with more expertise on this can take a look. I guess I'll run the code a few times, and check that it matches the vanilla version. |
||
| # its size, execute a scan to compute the byte offset where | ||
| # the calling rank should write its data, seek to proper | ||
| # spot, and copy the full file. | ||
| def merge_files_mpi_bin(outfile, infile, mpi, comm): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay I think the main trick is that binaries are just concatenated, which allows to leverage mpi. I think If you do work with multiprocessing you might interest yourself in the case the number of files differs from the number of workers you have. Essentially it should use something like Also you'd be able to refactor
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also please check it does the same thing for mmap and indexed dataset. I'm not sure in both case the merge is just the concatenation of bin files.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right, I've only looked at mmap files so far. I'm guessing the other file has a different file format, and so does not yet work with this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another key trick here is in computing the offsets. I think a multi-processing option should work, but there just needs to be a compute the various offsets for the starting byte offset of each file. One option might be to just have the main process stat each file and fill in a list of filesizes. Each process in the python multiprocess pool could then identify the subset of files it is responsible for and check the list to compute the offsets for each of those files.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think as long as the "merge" part is done in multiprocessing, it should still be quite fast no? If all you need is to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think that should be possible. Depending on the IndexedDataset type, there will be some number of values for which one needs |
||
| """Concatenate per-rank binary files into a new file given by outfile""" | ||
| import stat | ||
| import shutil | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these can go as the top now?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I'll move those up. |
||
|
|
||
| comm.barrier() | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Create shared output file. | ||
| f = mpi_create_file(outfile, mpi, comm) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm pretty sure we can use the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I think so. I'll take a look.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got the with syntax in there now. |
||
|
|
||
| # get file size of binary file for this rank | ||
| filesize = os.stat(infile)[stat.ST_SIZE] | ||
|
|
||
| # compute offset this rank should start copying | ||
| # its data into the merged file | ||
| offset = mpi_get_offset(filesize, mpi, comm) | ||
|
|
||
| # seek to appropriate offset and copy data | ||
| f.seek(offset) | ||
| with open(infile, "rb") as fsrc: | ||
| shutil.copyfileobj(fsrc, f) | ||
|
|
||
| f.close() | ||
|
|
||
| # TODO: check that all ranks wrote successfully | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm guessing you could check resulting bin s.t it's size is equal to the sum of all.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added some error handling here to verify that all ranks wrote successfully. If any rank throws an exception, they all will. |
||
| comm.barrier() | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def merge_files_mpi_idx(outfile, infile, mpi, comm, dtype): | ||
| rank = comm.Get_rank() | ||
| numranks = comm.Get_size() | ||
|
|
||
| comm.barrier() | ||
|
|
||
| # Create shared output file | ||
| f = mpi_create_file(outfile, mpi, comm) | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
| # Read the index file for the calling rank | ||
| index = MMapIndexedDataset.Index(infile) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's be generic, can be either MMapIndexDataset or the other one.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, we'll definitely want to support the other format.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added support for regular IndexedDataset. I've added that as a separate function. This function and the one for MMapIndexedDataset can both be refactored once we an API to support partial writes. |
||
| sizes = index.sizes | ||
| if rank == 0: | ||
| docs = index.doc_idx | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So careful, this has a strong assumption that |
||
| if rank != 0: | ||
| docs = index.doc_idx[1:] | ||
|
|
||
| # Compute total number of size and document index | ||
| # values across all ranks. Also compute the offset | ||
| # of the calling rank for each value considering | ||
| # the values of sizes/docs for all ranks before the | ||
| # calling rank. | ||
| numsizes = len(sizes) | ||
| numdocs = len(docs) | ||
| size_count = mpi_get_sum(numsizes, mpi, comm) | ||
| docs_count = mpi_get_sum(numdocs, mpi, comm) | ||
|
adammoody marked this conversation as resolved.
Outdated
|
||
| size_offset = mpi_get_offset(numsizes, mpi, comm) | ||
| docs_offset = mpi_get_offset(numdocs, mpi, comm) | ||
|
|
||
| # Have rank 0 write the file header | ||
| pos = 0 | ||
| if rank == 0: | ||
| f.write(MMapIndexedDataset.Index._HDR_MAGIC) | ||
| f.write(struct.pack('<Q', 1)) | ||
| f.write(struct.pack('<B', code(dtype))) | ||
| f.write(struct.pack('<Q', size_count)) | ||
| f.write(struct.pack('<Q', docs_count)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think here we want to abstract it to be generci to the other dataset index. Essentially we have to create a new class, something along the line of partial write. It needs to support the following;
|
||
| pos = f.tell() | ||
|
|
||
| # Broadcast value of pos from rank 0, | ||
| # and advance file position past file header on all ranks. | ||
| pos = mpi_get_sum(pos, mpi, comm) | ||
|
|
||
| # The list of size values from each rank are | ||
| # concatenated and stored as int32. | ||
| f.seek(pos + size_offset * np.int32().itemsize) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we abstract this into write? Essentially write needs 4 things:
|
||
| sizes32 = np.array(sizes, dtype=np.int32) | ||
| f.write(sizes32.tobytes(order='C')) | ||
| del sizes32 | ||
| pos += size_count * np.int32().itemsize | ||
|
thomasw21 marked this conversation as resolved.
Outdated
|
||
|
|
||
| # The pointer values store the byte offset to each sentence. | ||
| # A sentence has a variable number of tokens, given by | ||
| # its corresponding entry in the size array. Each token | ||
| # of a sentence is stored in units of type dtype, which consumes | ||
| # dtype().itemsize bytes (often a standard type that is just | ||
| # large enough to represent all elements of the vocabulary). | ||
|
|
||
| # First compute byte offsets for each sentence of our | ||
| # local set of sentences. | ||
| pointers = np.array(sizes, dtype=np.int64) | ||
| pointer_last = 0 | ||
| if len(sizes) > 0: | ||
| np.cumsum(pointers, axis=0, out=pointers) | ||
| pointers *= dtype().itemsize | ||
| pointer_last = pointers[-1] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not reuse the get_pointers method. |
||
|
|
||
| # Then account for bytes for all sentences on ranks | ||
| # before the calling rank. | ||
| pointer_offset = mpi_get_offset(pointer_last, mpi, comm) | ||
| pointers += pointer_offset | ||
|
|
||
| # Finally, zero-base the offset values by subtracting | ||
| # the number of bytes of the first sentence. To do that | ||
| # we first need to find the rank having the first sentence, | ||
| # then bcast that size to all ranks. | ||
| if size_count > 0: | ||
| # There is at least one sentence across all ranks, | ||
| # figure out which rank has the first sentence which | ||
| # is not necessarily rank 0. | ||
| minrank = numranks | ||
| if len(sizes) > 0: | ||
| minrank = rank | ||
| minrank = mpi_get_min(minrank, mpi, comm) | ||
|
|
||
| # Broadcast size of the first sentence from minrank. | ||
| # We "bcast" using all allreduce. | ||
| pointers_shift = 0 | ||
| if minrank == rank: | ||
| pointers_shift = pointers[0] | ||
| pointers_shift = mpi_get_sum(pointers_shift, mpi, comm) | ||
|
|
||
| # Zero-base pointers by subtracting size of first | ||
| # sentence from all values. | ||
| pointers -= pointers_shift | ||
|
|
||
| # Seek to proper offset for this rank and write | ||
| # pointer values into file, stored as int64. | ||
| f.seek(pos + size_offset * np.int64().itemsize) | ||
| f.write(pointers.tobytes(order='C')) | ||
| del pointers | ||
| pos += size_count * np.int64().itemsize | ||
|
|
||
| # The document index points to the position in the sizes | ||
| # array for the starting sentence of each document. | ||
| # A variable number of sentences can be in each document. | ||
| # Adjust document index for number of sentences that | ||
| # come before the calling rank. | ||
| doc_idx = np.array(docs, dtype=np.int64) | ||
| doc_idx += size_offset | ||
|
|
||
| # Seek to proper offset for this rank and write | ||
| # document index into file, stored as int64. | ||
| f.seek(pos + docs_offset * np.int64().itemsize) | ||
| f.write(doc_idx.tobytes(order='C')) | ||
| del doc_idx | ||
|
|
||
| f.close() | ||
|
|
||
| # TODO: check that all ranks wrote successfully | ||
| comm.barrier() | ||
|
|
||
|
|
||
| def merge_files_mpi(filemain, filerank, mpi, comm, dtype=np.int64): | ||
| # Concatenate the data files | ||
| merge_files_mpi_bin(data_file_path(filemain), data_file_path(filerank), mpi, comm) | ||
|
|
||
| # Combine index files into a single index file | ||
| merge_files_mpi_idx(index_file_path(filemain), index_file_path(filerank), mpi, comm, dtype) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep them?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree that it'd be useful to have these. When running with lots of procs, these status messages clutter up the output quite a bit, and they print in a random order since each process prints them independently.
It'd be good to have a way to silence them. For now, I commented them out for testing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't
rank_0force only one process to print? After reading the code this seems to work only totorch.distributed. I'd be a favor of droppingmpi4pyif it doesn't bring substantial performance improvement.