diff --git a/CMakeLists.txt b/CMakeLists.txt index 59027b5..fff9bf9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -233,6 +233,15 @@ if (PIXIE_TESTS) gtest_main ${PIXIE_DIAGNOSTICS_LIBS}) + add_executable(file_archive_tests + src/tests/file_archive_tests.cpp) + target_include_directories(file_archive_tests + PUBLIC include) + target_link_libraries(file_archive_tests + gtest + gtest_main + ${PIXIE_DIAGNOSTICS_LIBS}) + add_executable(storage_tests src/tests/storage_tests.cpp) target_include_directories(storage_tests @@ -299,6 +308,7 @@ if (PIXIE_TESTS) test_rmm tree_tests wavelet_tree_tests + file_archive_tests storage_tests serialization_tests excess_positions_tests @@ -394,6 +404,15 @@ if (PIXIE_BENCHMARKS) benchmark_main ${PIXIE_DIAGNOSTICS_LIBS}) + add_executable(file_archive_benchmarks + src/benchmarks/file_archive_benchmarks.cpp) + target_include_directories(file_archive_benchmarks + PUBLIC include) + target_link_libraries(file_archive_benchmarks + benchmark + benchmark_main + ${PIXIE_DIAGNOSTICS_LIBS}) + add_executable(serialization_benchmarks src/benchmarks/serialization_benchmarks.cpp) target_include_directories(serialization_benchmarks @@ -463,6 +482,7 @@ if (PIXIE_BENCHMARKS) rmq_benchmarks louds_tree_benchmarks wavelet_tree_benchmarks + file_archive_benchmarks serialization_benchmarks bp_tree_benchmarks dfuds_tree_benchmarks diff --git a/README.md b/README.md index a5182a2..6a7b622 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ call the inherited public facade, not SDSL-specific methods. int main() { const std::array text = {2, 0, 1, 2, 1, 0}; - pixie::WaveletTree tree(3, std::span(text)); + pixie::WaveletTree tree( + 3, std::span(text)); const auto ones_before_five = tree.rank(1, 5); // 2 const auto second_two = tree.select(2, 2); // 3 diff --git a/include/pixie/file_archive.h b/include/pixie/file_archive.h new file mode 100644 index 0000000..4f6b2d0 --- /dev/null +++ b/include/pixie/file_archive.h @@ -0,0 +1,718 @@ +#pragma once + +/** + * @file file_archive.h + * @brief Self-contained byte-oriented file archives with line extraction. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace pixie { + +/** @brief Kind of filesystem entry stored in a file archive. */ +enum class FileArchiveEntryType : std::uint8_t { + kRegular = 0, + kSymlink = 1, +}; + +/** @brief Source entry used to construct an owning file archive. */ +struct FileArchiveSource { + std::string path; + std::vector content; + FileArchiveEntryType type = FileArchiveEntryType::kRegular; + std::uint32_t mode = 0; +}; + +/** @brief Metadata for one replayable archive-construction source. */ +struct FileArchiveSourceMetadata { + std::string path; + FileArchiveEntryType type = FileArchiveEntryType::kRegular; + std::uint32_t mode = 0; +}; + +/** @brief Public metadata for one archive entry. */ +struct FileArchiveEntry { + std::string_view path; + std::size_t content_offset = 0; + std::size_t content_size = 0; + std::size_t newline_rank_base = 0; + std::size_t line_count = 0; + std::uint32_t mode = 0; + FileArchiveEntryType type = FileArchiveEntryType::kRegular; + bool is_text = false; +}; + +namespace file_archive_detail { + +inline constexpr std::array kMagic = {'P', 'I', 'X', 'A', + 'R', 'C', 'H', '1'}; +inline constexpr std::uint32_t kVersion = 5; +inline constexpr std::size_t kHeaderBytes = 3 * sizeof(std::uint64_t); +inline constexpr std::size_t kMetadataHeaderBytes = + kHeaderBytes + 2 * sizeof(std::uint32_t) + 5 * sizeof(std::uint64_t); +inline constexpr std::size_t kRecordBytes = + 6 * sizeof(std::uint64_t) + sizeof(std::uint32_t) + 4; +inline constexpr std::size_t kByteAlphabetSize = 256; + +struct FileRecord { + std::size_t path_offset = 0; + std::size_t path_size = 0; + std::size_t content_offset = 0; + std::size_t content_size = 0; + std::size_t newline_rank_base = 0; + std::size_t line_count = 0; + std::uint32_t mode = 0; + FileArchiveEntryType type = FileArchiveEntryType::kRegular; + bool is_text = false; +}; + +/** @brief Incrementally validate a byte stream as UTF-8. */ +class Utf8Validator { + public: + /** @brief Construct a validator, optionally rejecting embedded NUL bytes. */ + explicit Utf8Validator(bool reject_nul) : reject_nul_(reject_nul) {} + + /** @brief Consume the next contiguous chunk of the byte stream. */ + void Consume(std::span bytes) { + if (!valid_) { + return; + } + for (const std::byte raw : bytes) { + const std::uint8_t byte = std::to_integer(raw); + if (remaining_ == 0) { + if (byte == 0 && reject_nul_) { + valid_ = false; + return; + } + if (byte <= 0x7fU) { + continue; + } + if (byte >= 0xc2U && byte <= 0xdfU) { + code_point_ = byte & 0x1fU; + minimum_ = 0x80U; + remaining_ = 1; + } else if (byte >= 0xe0U && byte <= 0xefU) { + code_point_ = byte & 0x0fU; + minimum_ = 0x800U; + remaining_ = 2; + } else if (byte >= 0xf0U && byte <= 0xf4U) { + code_point_ = byte & 0x07U; + minimum_ = 0x10000U; + remaining_ = 3; + } else { + valid_ = false; + return; + } + continue; + } + if ((byte & 0xc0U) != 0x80U) { + valid_ = false; + return; + } + code_point_ = (code_point_ << 6U) | (byte & 0x3fU); + --remaining_; + if (remaining_ == 0 && + (code_point_ < minimum_ || code_point_ > 0x10ffffU || + (code_point_ >= 0xd800U && code_point_ <= 0xdfffU))) { + valid_ = false; + return; + } + } + } + + /** @brief Return whether all consumed bytes form complete valid UTF-8. */ + bool valid() const { return valid_ && remaining_ == 0; } + + private: + bool reject_nul_; + bool valid_ = true; + std::uint32_t code_point_ = 0; + std::uint32_t minimum_ = 0; + std::uint8_t remaining_ = 0; +}; + +inline bool IsUtf8(std::span bytes, bool reject_nul) { + Utf8Validator validator(reject_nul); + validator.Consume(bytes); + return validator.valid(); +} + +inline bool IsUtf8(std::string_view value, bool reject_nul) { + return IsUtf8(std::as_bytes(std::span(value.data(), value.size())), + reject_nul); +} + +inline std::size_t CheckedSize(std::uint64_t value) { + if (value > std::numeric_limits::max()) { + throw std::length_error("File-archive size does not fit in size_t"); + } + return static_cast(value); +} + +inline void RequireZeroBytes(std::span bytes, + std::size_t offset) { + for (const std::byte byte : bytes) { + if (byte != std::byte{0}) { + throw SerializationError("Non-zero file-archive padding", offset); + } + ++offset; + } +} + +} // namespace file_archive_detail + +/** + * @brief CRTP facade for file-archive lookup and extraction. + * @details `Impl` must provide stable record and path views, a queryable + * byte wavelet tree whose symbols cover all logical content, the logical + * content size, construction type, and path-storage size. Views returned by an + * implementation must remain valid for the facade object's lifetime. Owning + * implementations own this state; read-only implementations may retain + * non-owning views whose backing storage must outlive the facade. + * @tparam Impl Owning or read-only concrete archive implementation. + */ +template +class FileArchiveBase { + public: + /** @brief Return the serialized file-archive format version. */ + static constexpr std::uint32_t format_version() { + return file_archive_detail::kVersion; + } + + /** @brief Return the number of archived entries. */ + std::size_t size() const { return impl().records_impl().size(); } + + /** @brief Return whether the archive has no entries. */ + bool empty() const { return size() == 0; } + + /** @brief Return the number of logical content bytes, excluding framing. */ + std::size_t logical_size_bytes() const { return impl().logical_size_impl(); } + + /** @brief Return the wavelet-tree construction strategy. */ + WaveletTreeBuildType build_type() const { return impl().build_type_impl(); } + + /** @brief Return serialized fixed-record bytes. */ + std::size_t file_table_bytes() const { + return size() * file_archive_detail::kRecordBytes; + } + + /** @brief Return serialized path-blob bytes. */ + std::size_t path_storage_bytes() const { + return impl().path_storage_size_impl(); + } + + /** @brief Return framing, file-table, path, and alignment bytes. */ + std::size_t metadata_bytes() const { + const std::size_t unaligned = file_archive_detail::kMetadataHeaderBytes + + file_table_bytes() + path_storage_bytes(); + return unaligned + + (alignof(std::uint64_t) - unaligned % alignof(std::uint64_t)) % + alignof(std::uint64_t); + } + + /** + * @brief Find an exact archive-relative path. + * @return Its zero-based entry index, or `std::nullopt` when absent. + */ + std::optional find(std::string_view path) const { + const auto records = impl().records_impl(); + const auto position = + std::lower_bound(records.begin(), records.end(), path, + [this](const file_archive_detail::FileRecord& record, + std::string_view wanted) { + return impl().path_impl(record) < wanted; + }); + if (position == records.end() || impl().path_impl(*position) != path) { + return std::nullopt; + } + return static_cast(position - records.begin()); + } + + /** + * @brief Return metadata for an entry. + * @throws std::out_of_range if @p index is not an archived entry. + */ + FileArchiveEntry entry(std::size_t index) const { + const auto records = impl().records_impl(); + if (index >= records.size()) { + throw std::out_of_range("File-archive entry index is out of range"); + } + const auto& record = records[index]; + return {.path = impl().path_impl(record), + .content_offset = record.content_offset, + .content_size = record.content_size, + .newline_rank_base = record.newline_rank_base, + .line_count = record.line_count, + .mode = record.mode, + .type = record.type, + .is_text = record.is_text}; + } + + /** + * @brief Reconstruct the complete byte content of one entry. + * @throws std::out_of_range if @p index is not an archived entry. + */ + std::vector extract(std::size_t index) const { + const FileArchiveEntry metadata = entry(index); + return extract_range(metadata.content_offset, + metadata.content_offset + metadata.content_size); + } + + /** + * @brief Reconstruct zero-based half-open line range `[left, right)`. + * @details LF terminators are retained. Empty intervals are allowed. A + * trailing LF does not create an additional line. + * @throws std::out_of_range if @p index or `[left, right)` is out of range. + * @throws std::invalid_argument if the entry is not a regular text file. + */ + std::vector extract_lines(std::size_t index, + std::size_t left, + std::size_t right) const { + const FileArchiveEntry metadata = entry(index); + if (metadata.type != FileArchiveEntryType::kRegular) { + throw std::invalid_argument("Line extraction requires a regular file"); + } + if (!metadata.is_text) { + throw std::invalid_argument("Line extraction requires a text file"); + } + if (left > right || right > metadata.line_count) { + throw std::out_of_range("File-archive line range is out of range"); + } + const auto& tree = impl().tree_impl(); + const auto boundary = [&](std::size_t line) { + if (line == 0) { + return metadata.content_offset; + } + if (line == metadata.line_count) { + return metadata.content_offset + metadata.content_size; + } + return tree.select('\n', metadata.newline_rank_base + line) + 1; + }; + const std::size_t begin = boundary(left); + const std::size_t end = left == right ? begin : boundary(right); + return extract_range(begin, end); + } + + private: + const Impl& impl() const { return static_cast(*this); } + + std::vector extract_range(std::size_t begin, + std::size_t end) const { + const std::vector symbols = + impl().tree_impl().get_segment(begin, end); + std::vector bytes; + bytes.reserve(symbols.size()); + for (const std::uint8_t symbol : symbols) { + bytes.push_back(static_cast(symbol)); + } + return bytes; + } +}; + +/** @brief Owning byte-oriented file archive. */ +class FileArchive : public FileArchiveBase { + public: + FileArchive() = delete; + + /** + * @brief Owning archives are move-only. + * @details Internal rank indexes retain views into their wavelet-node bit + * storage, so copying without rebuilding those indexes would leave views + * bound to the source archive. + */ + FileArchive(const FileArchive&) = delete; + FileArchive& operator=(const FileArchive&) = delete; + FileArchive(FileArchive&&) noexcept = default; + FileArchive& operator=(FileArchive&&) noexcept = default; + + /** + * @brief Construct an archive from file and symlink sources. + * @details Sources are sorted by path. Paths must be non-empty, unique, + * valid UTF-8 strings. Content is preserved byte-for-byte. + */ + explicit FileArchive( + std::vector sources, + WaveletTreeBuildType build_type = WaveletTreeBuildType::Huffman) + : build_type_(build_type) { + build_sources(sources, [](const FileArchiveSource& source, auto&& consume) { + consume(std::span(source.content)); + }); + } + + /** + * @brief Construct from metadata and a replayable chunk reader. + * @details The reader is called twice per source in sorted path order. It + * receives `(const FileArchiveSourceMetadata&, consumer)` and must pass the + * same immutable content to `consumer` as byte spans on both calls. The + * first pass derives metadata and symbol counts; the second constructs the + * tree without retaining complete source contents. + * @throws std::invalid_argument for invalid metadata or changed content. + */ + template + FileArchive(std::vector sources, + ReadSource&& read_source, + WaveletTreeBuildType build_type = WaveletTreeBuildType::Huffman) + : build_type_(build_type) { + build_sources(sources, read_source); + } + + /** @brief Serialize one native, framed Pixie file archive. */ + void serialize(BinaryWriter& writer) const { + if (writer.size_bytes() % alignof(std::uint64_t) != 0) { + throw std::invalid_argument( + "File-archive serialization requires an aligned writer offset"); + } + const std::size_t begin = writer.size_bytes(); + detail::write_magic(writer, file_archive_detail::kMagic); + writer.write_u32(file_archive_detail::kVersion); + writer.write_u32(0); + const std::size_t size_position = writer.write_u64_placeholder(); + writer.write_u32(static_cast(build_type_)); + writer.write_u32(0); + writer.write_size(records_.size()); + writer.write_size(logical_size_); + writer.write_size(records_.size() * file_archive_detail::kRecordBytes); + writer.write_size(paths_.size()); + writer.write_size(0); + for (const auto& record : records_) { + writer.write_size(record.path_offset); + writer.write_size(record.path_size); + writer.write_size(record.content_offset); + writer.write_size(record.content_size); + writer.write_size(record.newline_rank_base); + writer.write_size(record.line_count); + writer.write_u32(record.mode); + writer.write_u8(static_cast(record.type)); + writer.write_u8(static_cast(record.is_text)); + writer.write_u16(0); + } + writer.write_bytes(std::as_bytes(std::span(paths_))); + writer.align_to(alignof(std::uint64_t)); + if (!tree_.has_value()) { + throw std::logic_error("Cannot serialize an uninitialized file archive"); + } + tree_->serialize(writer); + writer.align_to(alignof(std::uint64_t)); + writer.patch_u64(size_position, + static_cast(writer.size_bytes() - begin)); + } + + private: + template + void build_sources(std::vector& sources, ReadSource&& read_source) { + std::sort(sources.begin(), sources.end(), + [](const auto& left, const auto& right) { + return left.path < right.path; + }); + std::array + symbol_counts{}; + std::vector content_hashes; + content_hashes.reserve(sources.size()); + std::size_t newline_rank = 0; + for (const Source& source : sources) { + if (source.path.empty() || + !file_archive_detail::IsUtf8(source.path, true)) { + throw std::invalid_argument( + "File-archive paths must be non-empty UTF-8 strings"); + } + if (!records_.empty() && path_impl(records_.back()) == source.path) { + throw std::invalid_argument("File-archive paths must be unique"); + } + if (source.type != FileArchiveEntryType::kRegular && + source.type != FileArchiveEntryType::kSymlink) { + throw std::invalid_argument("Invalid file-archive entry type"); + } + + file_archive_detail::FileRecord record; + record.path_offset = paths_.size(); + record.path_size = source.path.size(); + record.content_offset = logical_size_; + record.newline_rank_base = newline_rank; + record.mode = source.mode; + record.type = source.type; + paths_.append(source.path); + + file_archive_detail::Utf8Validator utf8(/*reject_nul=*/true); + std::size_t newlines = 0; + bool has_content = false; + std::uint8_t last_byte = 0; + std::uint64_t content_hash = 14695981039346656037ULL; + read_source(source, [&](std::span chunk) { + if (chunk.size() > + std::numeric_limits::max() - logical_size_) { + throw std::length_error("File-archive content is too large"); + } + logical_size_ += chunk.size(); + utf8.Consume(chunk); + for (const std::byte byte : chunk) { + const std::uint8_t value = std::to_integer(byte); + ++symbol_counts[value]; + newlines += value == '\n' ? 1U : 0U; + has_content = true; + last_byte = value; + content_hash ^= value; + content_hash *= 1099511628211ULL; + } + }); + record.content_size = logical_size_ - record.content_offset; + record.is_text = utf8.valid(); + if (source.type == FileArchiveEntryType::kRegular && has_content) { + record.line_count = + newlines + static_cast(last_byte != '\n'); + } + newline_rank += newlines; + records_.push_back(record); + content_hashes.push_back(content_hash); + } + + tree_.emplace( + file_archive_detail::kByteAlphabetSize, symbol_counts, + [&](auto&& emit) { + for (std::size_t index = 0; index < sources.size(); ++index) { + std::size_t content_size = 0; + std::uint64_t content_hash = 14695981039346656037ULL; + read_source(sources[index], [&](std::span chunk) { + if (content_size > records_[index].content_size || + chunk.size() > records_[index].content_size - content_size) { + throw std::invalid_argument( + "File-archive source changed between build passes"); + } + content_size += chunk.size(); + for (const std::byte byte : chunk) { + const std::uint8_t value = std::to_integer(byte); + content_hash ^= value; + content_hash *= 1099511628211ULL; + emit(value); + } + }); + if (content_size != records_[index].content_size || + content_hash != content_hashes[index]) { + throw std::invalid_argument( + "File-archive source changed between build passes"); + } + } + }, + build_type_); + } + + /** @brief Permit the facade to use the documented extension points. */ + friend class FileArchiveBase; + + /** @brief Return a stable view of sorted records owned by this archive. */ + std::span records_impl() const { + return records_; + } + /** @brief Return the path slice identified by a valid record. */ + std::string_view path_impl( + const file_archive_detail::FileRecord& record) const { + return std::string_view(paths_).substr(record.path_offset, + record.path_size); + } + /** @brief Return the initialized byte tree containing archive content. */ + const WaveletTree& tree_impl() const { return *tree_; } + /** @brief Return the number of archived content bytes. */ + std::size_t logical_size_impl() const { return logical_size_; } + /** @brief Return the tree construction strategy. */ + WaveletTreeBuildType build_type_impl() const { return build_type_; } + /** @brief Return bytes occupied by concatenated paths. */ + std::size_t path_storage_size_impl() const { return paths_.size(); } + + std::vector records_; + std::string paths_; + std::optional> tree_; + std::size_t logical_size_ = 0; + WaveletTreeBuildType build_type_ = WaveletTreeBuildType::Huffman; +}; + +/** @brief Read-only file archive retaining views into serialized WT storage. */ +class FileArchiveView : public FileArchiveBase { + public: + FileArchiveView() = default; + + /** + * @brief Deserialize one framed archive and advance @p reader on success. + * @details The result retains views into the reader's backing bytes. The + * backing storage must outlive the returned archive. + */ + static FileArchiveView deserialize(BinaryReader& reader, + DeserializationValidation validation = + DeserializationValidation::kQuick) { + BinaryReader candidate = reader; + if (reinterpret_cast(candidate.remaining_bytes().data()) % + alignof(std::uint64_t) != + 0) { + throw std::invalid_argument( + "Serialized file archive is not word aligned"); + } + const std::size_t available = candidate.remaining(); + detail::require_magic(candidate, file_archive_detail::kMagic); + if (candidate.read_u32() != file_archive_detail::kVersion || + candidate.read_u32() != 0) { + throw std::invalid_argument("Incompatible serialized file archive"); + } + const std::size_t artifact_size = detail::checked_artifact_size( + candidate.read_u64(), file_archive_detail::kHeaderBytes, available); + BinaryReader payload = candidate.read_subreader( + artifact_size - file_archive_detail::kHeaderBytes); + + FileArchiveView result; + const std::uint32_t build_type = payload.read_u32(); + if (build_type > + static_cast(WaveletTreeBuildType::Huffman)) { + throw std::invalid_argument("Invalid file-archive build type"); + } + result.build_type_ = static_cast(build_type); + if (payload.read_u32() != 0) { + throw std::invalid_argument("Invalid file-archive reserved field"); + } + const std::size_t count = payload.read_size(); + result.logical_size_ = payload.read_size(); + const std::size_t records_bytes = payload.read_size(); + const std::size_t paths_bytes = payload.read_size(); + if (payload.read_size() != 0 || + count > std::numeric_limits::max() / + file_archive_detail::kRecordBytes || + records_bytes != count * file_archive_detail::kRecordBytes) { + throw std::invalid_argument("Invalid file-archive section sizes"); + } + + BinaryReader records_reader = payload.read_subreader(records_bytes); + result.records_.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + file_archive_detail::FileRecord record; + record.path_offset = records_reader.read_size(); + record.path_size = records_reader.read_size(); + record.content_offset = records_reader.read_size(); + record.content_size = records_reader.read_size(); + record.newline_rank_base = records_reader.read_size(); + record.line_count = records_reader.read_size(); + record.mode = records_reader.read_u32(); + const std::uint8_t type = records_reader.read_u8(); + const std::uint8_t is_text = records_reader.read_u8(); + if (type > static_cast(FileArchiveEntryType::kSymlink) || + is_text > 1 || records_reader.read_u16() != 0) { + throw std::invalid_argument("Invalid file-archive entry flags"); + } + record.type = static_cast(type); + record.is_text = is_text != 0; + result.records_.push_back(record); + } + if (!records_reader.empty()) { + throw std::invalid_argument("Invalid file-archive record section"); + } + const std::span paths = payload.read_bytes(paths_bytes); + result.paths_ = std::string_view( + reinterpret_cast(paths.data()), paths.size()); + const std::size_t padding = + (alignof(std::uint64_t) - + payload.byte_offset() % alignof(std::uint64_t)) % + alignof(std::uint64_t); + const std::size_t padding_offset = payload.byte_offset(); + file_archive_detail::RequireZeroBytes(payload.read_bytes(padding), + padding_offset); + result.tree_.emplace( + WaveletTreeView::deserialize(payload, validation)); + payload.require_zero_padding(alignof(std::uint64_t) - 1); + result.validate(validation == DeserializationValidation::kFull); + reader = candidate; + return result; + } + + private: + /** @brief Permit the facade to use the documented extension points. */ + friend class FileArchiveBase; + + void validate(bool full) const { + if (tree_->size() != logical_size_) { + throw std::invalid_argument("Invalid file-archive content size"); + } + const std::size_t newline_count = tree_->rank('\n', logical_size_); + std::size_t expected_content_offset = 0; + std::size_t expected_newline_rank = 0; + std::string_view previous_path; + for (std::size_t index = 0; index < records_.size(); ++index) { + const auto& record = records_[index]; + if (record.path_offset > paths_.size() || + record.path_size > paths_.size() - record.path_offset || + record.content_offset > logical_size_ || + record.content_size > logical_size_ - record.content_offset || + record.newline_rank_base > newline_count || + record.line_count > newline_count - record.newline_rank_base + 1 || + (record.type == FileArchiveEntryType::kSymlink && + record.line_count != 0)) { + throw std::invalid_argument("Invalid file-archive entry bounds"); + } + if (!full) { + continue; + } + const std::string_view path = path_impl(record); + if (path.empty() || !file_archive_detail::IsUtf8(path, true) || + (index != 0 && previous_path >= path) || + record.content_offset != expected_content_offset || + record.newline_rank_base != expected_newline_rank) { + throw std::invalid_argument("Invalid file-archive entry metadata"); + } + const std::vector content = extract(index); + const bool is_text = file_archive_detail::IsUtf8(content, true); + std::size_t newlines = 0; + for (const std::byte byte : content) { + newlines += byte == std::byte{'\n'} ? 1U : 0U; + } + std::size_t line_count = 0; + if (record.type == FileArchiveEntryType::kRegular && !content.empty()) { + line_count = newlines + static_cast(content.back() != + std::byte{'\n'}); + } + if (record.is_text != is_text || record.line_count != line_count) { + throw std::invalid_argument("Invalid file-archive derived metadata"); + } + previous_path = path; + expected_content_offset += content.size(); + expected_newline_rank += newlines; + } + if (full && expected_content_offset != logical_size_) { + throw std::invalid_argument("Invalid file-archive content layout"); + } + } + + /** @brief Return a stable view of validated, sorted records. */ + std::span records_impl() const { + return records_; + } + /** @brief Return the path slice identified by a validated record. */ + std::string_view path_impl( + const file_archive_detail::FileRecord& record) const { + return paths_.substr(record.path_offset, record.path_size); + } + /** @brief Return the view-backed byte tree containing archive content. */ + const WaveletTreeView& tree_impl() const { return *tree_; } + /** @brief Return the number of archived content bytes. */ + std::size_t logical_size_impl() const { return logical_size_; } + /** @brief Return the serialized tree construction strategy. */ + WaveletTreeBuildType build_type_impl() const { return build_type_; } + /** @brief Return bytes occupied by the retained path blob. */ + std::size_t path_storage_size_impl() const { return paths_.size(); } + + std::vector records_; + std::string_view paths_; + std::optional> tree_; + std::size_t logical_size_ = 0; + WaveletTreeBuildType build_type_ = WaveletTreeBuildType::Huffman; +}; + +} // namespace pixie diff --git a/include/pixie/file_archive/implementations.h b/include/pixie/file_archive/implementations.h new file mode 100644 index 0000000..414d25b --- /dev/null +++ b/include/pixie/file_archive/implementations.h @@ -0,0 +1,8 @@ +#pragma once + +/** + * @file implementations.h + * @brief Native Pixie file-archive owning and read-only types. + */ + +#include diff --git a/include/pixie/rank_select/support.h b/include/pixie/rank_select/support.h index 0cc5d5d..142e84e 100644 --- a/include/pixie/rank_select/support.h +++ b/include/pixie/rank_select/support.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -75,8 +76,21 @@ class RankSelectSupport constexpr static size_t kBlocksPerSuperBlock = 128; constexpr static size_t kSelectSampleFrequency = 16384; - alignas(64) uint64_t delta_super[8]{}; - alignas(64) uint16_t delta_basic[32]{}; + alignas(64) inline static constexpr std::array kDeltaSuper = [] { + std::array result{}; + for (size_t i = 0; i < result.size(); ++i) { + result[i] = i * kSuperBlockSize; + } + return result; + }(); + alignas(64) inline static constexpr std::array kDeltaBasic = + [] { + std::array result{}; + for (size_t i = 0; i < result.size(); ++i) { + result[i] = static_cast(i * kBasicBlockSize); + } + return result; + }(); MetadataStorage super_block_rank_; // 64-bit global prefix sums MetadataStorage basic_block_rank_; // 16-bit local prefix sums @@ -118,10 +132,6 @@ class RankSelectSupport } return ReadOnlyStorageView(bytes); } else { - if (size % kAlignedStorageLineBytes != 0) { - throw std::invalid_argument( - "Serialized rank/select storage is not cache-line aligned"); - } if (size > std::numeric_limits::max() / 8) { throw std::length_error("Serialized rank/select storage is too large"); } @@ -153,22 +163,12 @@ class RankSelectSupport "Invalid serialized rank/select configuration"); } - std::size_t num_superblocks = - 8 + (padded_size_ == 0 ? 0 : (padded_size_ - 1) / kSuperBlockSize); - if (num_superblocks > std::numeric_limits::max() - 7) { - throw std::length_error( - "Serialized rank/select super-block count is too large"); - } - num_superblocks = ((num_superblocks + 7) / 8) * 8; - if (num_superblocks > std::numeric_limits::max() / - (kBlocksPerSuperBlock * sizeof(std::uint16_t))) { - throw std::length_error( - "Serialized rank/select basic-block count is too large"); - } + const std::size_t data_superblocks = data_superblock_count(); + const std::size_t super_entries = data_superblocks + 1; const std::size_t expected_super_bytes = - num_superblocks * sizeof(std::uint64_t); + super_entries * sizeof(std::uint64_t); const std::size_t expected_basic_bytes = - num_superblocks * kBlocksPerSuperBlock * sizeof(std::uint16_t); + stored_basicblock_count() * sizeof(std::uint16_t); if (super_block_rank_.size_bytes() != expected_super_bytes || basic_block_rank_.size_bytes() != expected_basic_bytes || select_samples_.size_bytes() % sizeof(std::uint64_t) != 0) { @@ -189,30 +189,19 @@ class RankSelectSupport throw std::invalid_argument( "Invalid serialized rank/select sample metadata"); } - for (std::size_t i = 0; i < 8; ++i) { - if (delta_super[i] != i * kSuperBlockSize) { - throw std::invalid_argument( - "Invalid serialized rank/select SIMD metadata"); - } - } - for (std::size_t i = 0; i < 32; ++i) { - if (delta_basic[i] != i * kBasicBlockSize) { - throw std::invalid_argument( - "Invalid serialized rank/select SIMD metadata"); - } - } - if (validation == DeserializationValidation::kFull) { validate_full_source_metadata(); return; } const auto samples = select_samples_.as_words64(); - const auto sample_values_fit = [samples, num_superblocks]( + const auto sample_values_fit = [samples, data_superblocks]( std::size_t begin, std::size_t count) { return std::ranges::all_of(samples.subspan(begin, count), - [num_superblocks](std::uint64_t sample) { - return sample < num_superblocks; + [data_superblocks](std::uint64_t sample) { + return data_superblocks == 0 + ? sample == 0 + : sample < data_superblocks; }); }; if (!sample_values_fit(select1_sample_begin_, select1_sample_count_) || @@ -312,7 +301,8 @@ class RankSelectSupport rank0 += zeros; } - if (super_rank + basic_rank != max_rank_ || rank1 != max_rank_ || + if (super_blocks.back() != max_rank_ || + super_rank + basic_rank != max_rank_ || rank1 != max_rank_ || next_select1 != select1_end || next_select0 != select0_end) { throw std::invalid_argument( "Serialized rank/select totals disagree with source"); @@ -323,6 +313,28 @@ class RankSelectSupport return (num_bits_ + kWordSize - 1) / kWordSize; } + size_t data_superblock_count() const { + return num_bits_ == 0 ? 0 : 1 + (num_bits_ - 1) / kSuperBlockSize; + } + + template + static ReadOnlyStorageView complete_word_view( + const SourceStorage& source_storage) { + if constexpr (requires { source_storage.padded_view(); }) { + return source_storage.padded_view(); + } else { + return source_storage.view(); + } + } + + size_t stored_basicblock_count() const { + if (num_bits_ == 0) { + return 0; + } + const size_t data_basicblocks = 1 + (num_bits_ - 1) / kBasicBlockSize; + return (data_basicblocks + 31) / 32 * 32; + } + size_t logical_word_bits(size_t word_index) const { const size_t begin = word_index * kWordSize; if (begin >= num_bits_) { @@ -550,14 +562,13 @@ class RankSelectSupport void build_rank_select(SelectSupport support, std::optional one_count) { select_support_ = support; - size_t num_superblocks = - 8 + (padded_size_ == 0 ? 0 : (padded_size_ - 1) / kSuperBlockSize); - // Add more blocks to ease SIMD processing - // num_basicblocks to fully cover superblock, i.e. 128 - // This reduces branching in select - num_superblocks = ((num_superblocks + 7) / 8) * 8; - size_t num_basicblocks = num_superblocks * kBlocksPerSuperBlock; - super_block_rank_.resize(num_superblocks * 64); + const size_t data_superblocks = data_superblock_count(); + // Keep complete 32-entry SIMD chunks. Full 128-entry superblock padding is + // unnecessary because only the terminal superblock can be partial. The + // extra super entry is the cumulative-rank sentinel used by interpolation + // search. + const size_t num_basicblocks = stored_basicblock_count(); + super_block_rank_.resize((data_superblocks + 1) * 64); basic_block_rank_.resize(num_basicblocks * 16); auto super_block_rank = super_block_rank_.writable_words64(); @@ -616,14 +627,8 @@ class RankSelectSupport } } max_rank_ = super_block_sum + basic_block_sum; + super_block_rank[data_superblocks] = max_rank_; finalize_select_sample_writers(select_writers); - - for (size_t i = 0; i < 8; ++i) { - delta_super[i] = i * kSuperBlockSize; - } - for (size_t i = 0; i < 32; ++i) { - delta_basic[i] = i * kBasicBlockSize; - } } /** @@ -667,16 +672,18 @@ class RankSelectSupport uint64_t left = select0_sample(rank0 / kSelectSampleFrequency); while (left + 7 < super_block_rank.size()) { - auto len = lower_bound_delta_8x64(&super_block_rank[left], rank0, - delta_super, kSuperBlockSize * left); + auto len = + lower_bound_delta_8x64(&super_block_rank[left], rank0, + kDeltaSuper.data(), kSuperBlockSize * left); if (len < 8) { return left + len - 1; } left += 8; } if (left + 3 < super_block_rank.size()) { - auto len = lower_bound_delta_4x64(&super_block_rank[left], rank0, - delta_super, kSuperBlockSize * left); + auto len = + lower_bound_delta_4x64(&super_block_rank[left], rank0, + kDeltaSuper.data(), kSuperBlockSize * left); if (len < 4) { return left + len - 1; } @@ -702,15 +709,18 @@ class RankSelectSupport */ uint64_t find_basicblock(uint16_t local_rank, uint64_t s_block) const { auto basic_block_rank = basic_block_rank_.as_words16(); + const size_t block_begin = kBlocksPerSuperBlock * s_block; + const size_t block_count = + std::min(kBlocksPerSuperBlock, basic_block_rank.size() - block_begin); - for (size_t pos = 0; pos < kBlocksPerSuperBlock; pos += 32) { - auto count = lower_bound_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank); + for (size_t pos = 0; pos < block_count; pos += 32) { + auto count = + lower_bound_32x16(&basic_block_rank[block_begin + pos], local_rank); if (count < 32) { - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } } - return kBlocksPerSuperBlock * s_block + kBlocksPerSuperBlock - 1; + return block_begin + block_count - 1; } /** @@ -726,15 +736,18 @@ class RankSelectSupport */ uint64_t find_basicblock_zeros(uint16_t local_rank0, uint64_t s_block) const { auto basic_block_rank = basic_block_rank_.as_words16(); - for (size_t pos = 0; pos < kBlocksPerSuperBlock; pos += 32) { - auto count = lower_bound_delta_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank0, - delta_basic, kBasicBlockSize * pos); + const size_t block_begin = kBlocksPerSuperBlock * s_block; + const size_t block_count = + std::min(kBlocksPerSuperBlock, basic_block_rank.size() - block_begin); + for (size_t pos = 0; pos < block_count; pos += 32) { + auto count = lower_bound_delta_32x16(&basic_block_rank[block_begin + pos], + local_rank0, kDeltaBasic.data(), + kBasicBlockSize * pos); if (count < 32) { - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } } - return kBlocksPerSuperBlock * s_block + kBlocksPerSuperBlock - 1; + return block_begin + block_count - 1; } /** @@ -757,31 +770,35 @@ class RankSelectSupport uint64_t find_basicblock_is(uint16_t local_rank, uint64_t s_block) const { auto super_block_rank = super_block_rank_.as_words64(); auto basic_block_rank = basic_block_rank_.as_words16(); + const size_t block_begin = kBlocksPerSuperBlock * s_block; + const size_t block_count = + std::min(kBlocksPerSuperBlock, basic_block_rank.size() - block_begin); + const size_t last_group = block_count - 32; auto lower = super_block_rank[s_block]; auto upper = super_block_rank[s_block + 1]; - uint64_t pos = kBlocksPerSuperBlock * local_rank / (upper - lower); + uint64_t pos = block_count * local_rank / (upper - lower); pos = pos + 16 < 32 ? 0 : (pos - 16); - pos = pos > 96 ? 96 : pos; - while (pos < 96) { - auto count = lower_bound_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank); + pos = std::min(pos, last_group); + while (pos < last_group) { + auto count = + lower_bound_32x16(&basic_block_rank[block_begin + pos], local_rank); if (count == 0) { return find_basicblock(local_rank, s_block); } if (count < 32) { - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } pos += 32; } - pos = 96; - auto count = lower_bound_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank); + pos = last_group; + auto count = + lower_bound_32x16(&basic_block_rank[block_begin + pos], local_rank); if (count == 0) { return find_basicblock(local_rank, s_block); } - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } /** @@ -797,22 +814,25 @@ class RankSelectSupport uint64_t s_block) const { auto super_block_rank = super_block_rank_.as_words64(); auto basic_block_rank = basic_block_rank_.as_words16(); + const size_t block_begin = kBlocksPerSuperBlock * s_block; + const size_t block_count = + std::min(kBlocksPerSuperBlock, basic_block_rank.size() - block_begin); + const size_t last_group = block_count - 32; auto lower = kSuperBlockSize * s_block - super_block_rank[s_block]; auto upper = kSuperBlockSize * (s_block + 1) - super_block_rank[s_block + 1]; - uint64_t interpolation = - kBlocksPerSuperBlock * local_rank0 / (upper - lower); + uint64_t interpolation = block_count * local_rank0 / (upper - lower); // Random data usually places the interpolation estimate in the target // block. Validate it from existing one-prefix metadata before the SIMD // derived-zero scan. const uint64_t block_offset = - std::min(interpolation, kBlocksPerSuperBlock - 1); - const uint64_t block = kBlocksPerSuperBlock * s_block + block_offset; + std::min(interpolation, block_count - 1); + const uint64_t block = block_begin + block_offset; const uint64_t zero_before = kBasicBlockSize * block_offset - basic_block_rank[block]; - const uint64_t zero_after = block_offset + 1 == kBlocksPerSuperBlock + const uint64_t zero_after = block_offset + 1 == block_count ? upper - lower : kBasicBlockSize * (block_offset + 1) - basic_block_rank[block + 1]; @@ -822,27 +842,27 @@ class RankSelectSupport uint64_t pos = interpolation; pos = pos + 16 < 32 ? 0 : (pos - 16); - pos = pos > 96 ? 96 : pos; - while (pos < 96) { - auto count = lower_bound_delta_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank0, - delta_basic, kBasicBlockSize * pos); + pos = std::min(pos, last_group); + while (pos < last_group) { + auto count = lower_bound_delta_32x16(&basic_block_rank[block_begin + pos], + local_rank0, kDeltaBasic.data(), + kBasicBlockSize * pos); if (count == 0) { return find_basicblock_zeros(local_rank0, s_block); } if (count < 32) { - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } pos += 32; } - pos = 96; - auto count = lower_bound_delta_32x16( - &basic_block_rank[kBlocksPerSuperBlock * s_block + pos], local_rank0, - delta_basic, kBasicBlockSize * pos); + pos = last_group; + auto count = lower_bound_delta_32x16(&basic_block_rank[block_begin + pos], + local_rank0, kDeltaBasic.data(), + kBasicBlockSize * pos); if (count == 0) { return find_basicblock_zeros(local_rank0, s_block); } - return kBlocksPerSuperBlock * s_block + pos + count - 1; + return block_begin + pos + count - 1; } public: @@ -953,8 +973,8 @@ class RankSelectSupport size_t num_bits, SelectSupport select_support = SelectSupport::kBoth, std::optional one_count = std::nullopt) - : RankSelectSupport(source_storage.view(), - num_bits, + : RankSelectSupport(complete_word_view(source_storage), + std::min(num_bits, source_storage.size_bits()), select_support, one_count) {} @@ -1094,12 +1114,6 @@ class RankSelectSupport writer.write_size(select0_sample_count_); writer.write_u32(static_cast(select_support_)); writer.write_u32(static_cast(select0_samples_reversed_)); - for (const uint64_t delta : delta_super) { - writer.write_u64(delta); - } - for (const uint16_t delta : delta_basic) { - writer.write_u16(delta); - } super_block_rank_.serialize(writer); basic_block_rank_.serialize(writer); select_samples_.serialize(writer); @@ -1152,12 +1166,6 @@ class RankSelectSupport "Invalid serialized rank/select boolean value"); } result.select0_samples_reversed_ = reversed != 0; - for (uint64_t& delta : result.delta_super) { - delta = candidate.read_u64(); - } - for (uint16_t& delta : result.delta_basic) { - delta = candidate.read_u16(); - } result.super_block_rank_ = deserialize_metadata_storage(candidate); result.basic_block_rank_ = deserialize_metadata_storage(candidate); result.select_samples_ = deserialize_metadata_storage(candidate); diff --git a/include/pixie/rmq/cartesian_hybrid_btree.h b/include/pixie/rmq/cartesian_hybrid_btree.h index b4c7897..d9eed13 100644 --- a/include/pixie/rmq/cartesian_hybrid_btree.h +++ b/include/pixie/rmq/cartesian_hybrid_btree.h @@ -1781,7 +1781,7 @@ class CartesianHybridBTree static constexpr std::array kSerializationMagic = { 'P', 'I', 'X', 'I', 'E', 'R', 'M', 'Q'}; - static constexpr std::uint32_t kSerializationVersion = 1; + static constexpr std::uint32_t kSerializationVersion = 4; static constexpr std::size_t kSerializationHeaderBytes = 48; public: @@ -2158,10 +2158,6 @@ class CartesianHybridBTree BinaryReader& reader) { const std::size_t size = reader.read_size(); const std::span bytes = reader.read_bytes(size); - if (size % kAlignedStorageLineBytes != 0) { - throw std::invalid_argument( - "Serialized RMQ storage is not cache-line aligned"); - } if (size > std::numeric_limits::max() / 8) { throw std::length_error("Serialized RMQ storage is too large"); } diff --git a/include/pixie/storage.h b/include/pixie/storage.h index ec17220..12ad091 100644 --- a/include/pixie/storage.h +++ b/include/pixie/storage.h @@ -26,7 +26,11 @@ namespace pixie { template class StorageBase { public: - /** @brief Return the exposed storage size in bytes. */ + /** + * @brief Return the logical exposed storage size in bytes. + * @details An owning implementation may reserve or pad more memory; use + * `allocated_bytes()` when that physical allocation size is required. + */ std::size_t size_bytes() const { return impl().size_bytes_impl(); } /** @brief Return the exposed storage size in bits. */ @@ -35,7 +39,7 @@ class StorageBase { /** @brief Check whether the storage is empty. */ bool empty() const { return size_bytes() == 0; } - /** @brief Return a read-only view of all exposed bytes. */ + /** @brief Return a read-only view of all logical exposed bytes. */ std::span as_bytes() const { return impl().as_bytes_impl(); } /** diff --git a/include/pixie/storage/aligned.h b/include/pixie/storage/aligned.h index 59d5869..f3ee78b 100644 --- a/include/pixie/storage/aligned.h +++ b/include/pixie/storage/aligned.h @@ -3,10 +3,13 @@ #include #include +#include #include #include #include +#include #include +#include #include namespace pixie { @@ -28,11 +31,12 @@ static_assert(alignof(CacheLine) == kAlignedStorageLineBytes); static_assert(sizeof(CacheLine) == kAlignedStorageLineBytes); /** - * @brief Owning storage rounded up to 64-byte blocks. + * @brief Owning storage with a logical byte size and 64-byte-aligned backing. * - * @details Construction and resize accept a logical bit count. All exposed - * views cover the padded allocation. Resizing or destroying this object - * invalidates its read-only views. + * @details Construction and resize accept a logical bit count. Exposed byte + * and word views cover `ceil(size_bits / 8)` logical bytes, while the backing + * allocation remains rounded up to complete cache lines. Resizing or + * destroying this object invalidates its read-only views. */ class AlignedStorage : public StorageBase { public: @@ -40,16 +44,41 @@ class AlignedStorage : public StorageBase { /** @brief Construct storage for at least @p size_bits bits. */ explicit AlignedStorage(std::size_t size_bits) - : data_(lines_for_bits(size_bits)) {} + : logical_size_bytes_(bytes_for_bits(size_bits)), + data_(lines_for_bits(size_bits)) {} - /** @brief Return the padded allocation size in bytes. */ - std::size_t size_bytes_impl() const { + /** @brief Copy complete 64-bit words into aligned owning storage. */ + explicit AlignedStorage(std::span words) + : AlignedStorage(bit_size_for_words(words.size())) { + std::copy(words.begin(), words.end(), writable_words64_impl().begin()); + } + + /** @brief Return the logical number of exposed bytes. */ + std::size_t size_bytes_impl() const { return logical_size_bytes_; } + + /** @brief Return the logical number of exposed bytes. */ + std::size_t logical_size_bytes() const { return logical_size_bytes_; } + + /** @brief Return the cache-line-rounded backing size in bytes. */ + std::size_t padded_size_bytes() const { return data_.size() * kAlignedStorageLineBytes; } - /** @brief Return the padded allocation as read-only bytes. */ + /** + * @brief Return a non-owning view of the complete cache-line backing. + * @details This view includes allocation padding and is intended for + * word-oriented indexes that require a complete final word. Serialization + * and ordinary storage views continue to expose only logical bytes. + */ + ReadOnlyStorageView padded_view() const { + return ReadOnlyStorageView( + std::as_bytes(std::span(data_))); + } + + /** @brief Return the logical bytes as a read-only span. */ std::span as_bytes_impl() const { - return std::as_bytes(std::span(data_)); + return std::as_bytes(std::span(data_)) + .first(logical_size_bytes_); } /** @brief Return a checked read-only byte subrange. */ @@ -66,23 +95,25 @@ class AlignedStorage : public StorageBase { /** @brief Resize to hold at least @p size_bits bits. */ void resize_impl(std::size_t size_bits) { data_.resize(lines_for_bits(size_bits)); + logical_size_bytes_ = bytes_for_bits(size_bits); } - /** @brief Return writable allocation bytes. */ + /** @brief Return writable logical bytes. */ std::span writable_bytes_impl() { - return std::as_writable_bytes(std::span(data_)); + return std::as_writable_bytes(std::span(data_)) + .first(logical_size_bytes_); } - /** @brief Return writable allocation as 16-bit words. */ + /** @brief Return writable logical storage as 16-bit words. */ std::span writable_words16_impl() { return {reinterpret_cast(data_.data()), - data_.size() * kAlignedStorageLineWords16}; + logical_size_bytes_ / sizeof(std::uint16_t)}; } - /** @brief Return writable allocation as 64-bit words. */ + /** @brief Return writable logical storage as 64-bit words. */ std::span writable_words64_impl() { return {reinterpret_cast(data_.data()), - data_.size() * kAlignedStorageLineWords64}; + logical_size_bytes_ / sizeof(std::uint64_t)}; } /** @brief Return bytes reserved by the underlying vector. */ @@ -100,11 +131,25 @@ class AlignedStorage : public StorageBase { std::span as_lines() const { return data_; } private: + static std::size_t bit_size_for_words(std::size_t word_count) { + constexpr std::size_t kWordBits = + std::numeric_limits::digits; + if (word_count > std::numeric_limits::max() / kWordBits) { + throw std::length_error("Aligned storage word sequence is too large"); + } + return word_count * kWordBits; + } + + static constexpr std::size_t bytes_for_bits(std::size_t size_bits) { + return size_bits / 8 + (size_bits % 8 != 0); + } + static constexpr std::size_t lines_for_bits(std::size_t size_bits) { return size_bits / kAlignedStorageLineBits + (size_bits % kAlignedStorageLineBits != 0); } + std::size_t logical_size_bytes_ = 0; std::vector data_; }; diff --git a/include/pixie/wavelet_tree.h b/include/pixie/wavelet_tree.h index 1378e14..6d62293 100644 --- a/include/pixie/wavelet_tree.h +++ b/include/pixie/wavelet_tree.h @@ -8,6 +8,7 @@ * storage-backed wavelet trees. */ +#include #include #include #include @@ -17,13 +18,21 @@ namespace pixie { /** @brief Construction strategy for a wavelet-tree implementation. */ enum class WaveletTreeBuildType { Standard, Huffman }; +/** @brief Unsigned code-unit type indexed by a wavelet tree. */ +template +concept WaveletTreeSymbol = std::unsigned_integral && !std::same_as; + /** * @brief CRTP facade for wavelet-tree queries. * * @see `` for the available concrete * implementations. + * @tparam Impl Concrete implementation exposing the documented `*_impl()` + * extension points. + * @tparam Symbol Unsigned symbol type returned by access operations and + * accepted by rank/select operations. */ -template +template class WaveletTreeBase { public: /** @@ -44,7 +53,7 @@ class WaveletTreeBase { * @param end_position Prefix boundary in `[0, size()]`. * @return Number of occurrences, or zero for a symbol outside the alphabet. */ - std::size_t rank(std::uint64_t symbol, std::size_t end_position) const { + std::size_t rank(Symbol symbol, std::size_t end_position) const { return impl().rank_impl(symbol, end_position); } @@ -54,7 +63,7 @@ class WaveletTreeBase { * @param rank One-based occurrence rank. * @return Zero-based sequence position, or `size()` when absent. */ - std::size_t select(std::uint64_t symbol, std::size_t rank) const { + std::size_t select(Symbol symbol, std::size_t rank) const { return impl().select_impl(symbol, rank); } @@ -64,8 +73,7 @@ class WaveletTreeBase { * @param end One past the last sequence position; must not exceed `size()`. * @return Symbols in the requested half-open range. */ - std::vector get_segment(std::size_t begin, - std::size_t end) const { + std::vector get_segment(std::size_t begin, std::size_t end) const { return impl().get_segment_impl(begin, end); } diff --git a/include/pixie/wavelet_tree/implementations.h b/include/pixie/wavelet_tree/implementations.h index 34510dc..35a22a7 100644 --- a/include/pixie/wavelet_tree/implementations.h +++ b/include/pixie/wavelet_tree/implementations.h @@ -4,9 +4,9 @@ * @file implementations.h * @brief All wavelet-tree implementations provided by Pixie. * - * - `WaveletTreeIndex`: storage-parameterized wavelet tree. - * - `WaveletTree`: owning aligned-storage alias. - * - `WaveletTreeView`: non-owning read-only storage view alias. + * - `WaveletTreeIndex`: typed, storage-parameterized tree. + * - `WaveletTree`: owning aligned-storage alias. + * - `WaveletTreeView`: non-owning read-only storage view alias. */ #include diff --git a/include/pixie/wavelet_tree/index.h b/include/pixie/wavelet_tree/index.h index 3f32f30..a92ceef 100644 --- a/include/pixie/wavelet_tree/index.h +++ b/include/pixie/wavelet_tree/index.h @@ -14,27 +14,37 @@ #include #include #include +#include +#include #include namespace pixie { -template -class WaveletTreeIndex : public WaveletTreeBase> { +/** + * @brief Storage-backed wavelet tree over an unsigned symbol type. + * @tparam Symbol Unsigned symbol type. Its value range must cover the dense + * alphabet `[0, alphabet_size)`. + * @tparam Storage Owning aligned storage or a non-owning read-only view. + */ +template +class WaveletTreeIndex + : public WaveletTreeBase, Symbol> { private: using node_index_t = size_t; static constexpr node_index_t npos = std::numeric_limits::max(); static constexpr std::array kSerializationMagic = { 'P', 'X', 'W', 'A', 'V', 'E', 'T', '\0'}; - static constexpr std::uint32_t kSerializationVersion = 1; + static constexpr std::uint32_t kSerializationVersion = 5; static constexpr std::size_t kSerializationHeaderBytes = 24; struct PreWaveletNode { node_index_t parent = npos; node_index_t left_child = npos; node_index_t right_child = npos; - uint64_t middle; + std::size_t middle; PackedBitBuilder stream; - explicit PreWaveletNode(uint64_t middle) : middle(middle) {} + explicit PreWaveletNode(std::size_t middle) : middle(middle) {} }; /** @@ -47,19 +57,37 @@ class WaveletTreeIndex : public WaveletTreeBase> { */ struct WaveletNode { node_index_t parent, left_child, right_child; - uint64_t middle; + std::size_t middle; Storage bit_vector_data; RankSelectSupport data; - /** @brief Manually turns std::vector into AlignedStorage */ - static AlignedStorage align(std::vector&& data) { - AlignedStorage result(data.size() * 64); - auto view = result.writable_words64(); - std::copy(data.begin(), data.end(), view.begin()); - return result; + WaveletNode() = default; + + WaveletNode(const WaveletNode& node) + : parent(node.parent), + left_child(node.left_child), + right_child(node.right_child), + middle(node.middle), + bit_vector_data(node.bit_vector_data), + data([&] { + if constexpr (std::same_as) { + return RankSelectSupport(bit_vector_data.as_words64(), + node.data.size()); + } else { + return node.data; + } + }()) {} + + WaveletNode& operator=(const WaveletNode& node) { + if (this != &node) { + WaveletNode copy(node); + *this = std::move(copy); + } + return *this; } - WaveletNode() = default; + WaveletNode(WaveletNode&&) noexcept = default; + WaveletNode& operator=(WaveletNode&&) noexcept = default; WaveletNode(PreWaveletNode&& node) requires(std::same_as) @@ -68,7 +96,8 @@ class WaveletTreeIndex : public WaveletTreeBase> { right_child(node.right_child), middle(node.middle) { const std::size_t bit_count = node.stream.size_bits(); - bit_vector_data = align(node.stream.take_words()); + const std::vector words = node.stream.take_words(); + bit_vector_data = AlignedStorage(std::span(words)); data = RankSelectSupport(bit_vector_data.as_words64(), bit_count); } @@ -100,8 +129,9 @@ class WaveletTreeIndex : public WaveletTreeBase> { } }; - size_t alphabet_size_, data_size_; - node_index_t root_; + size_t alphabet_size_ = 0; + size_t data_size_ = 0; + node_index_t root_ = npos; std::vector nodes_; std::vector leaves_; std::vector permutation_, inverse_permutation_; @@ -277,8 +307,8 @@ class WaveletTreeIndex : public WaveletTreeBase> { void copy_segment_content(node_index_t node, size_t begin, size_t end, - std::span dst, - std::span tmp) const { + std::span dst, + std::span tmp) const { if (begin == end) { return; } @@ -287,15 +317,16 @@ class WaveletTreeIndex : public WaveletTreeBase> { left = (end - begin) - right; if (nodes_[node].left_child == npos) { - std::fill_n(tmp.begin(), static_cast(left), - inverse_permutation_[nodes_[node].middle - 1]); + std::fill_n( + tmp.begin(), static_cast(left), + static_cast(inverse_permutation_[nodes_[node].middle - 1])); } else { copy_segment_content(nodes_[node].left_child, rank0, rank0 + left, tmp.subspan(0, left), dst.subspan(0, left)); } if (nodes_[node].right_child == npos) { std::fill(tmp.begin() + static_cast(left), tmp.end(), - inverse_permutation_[nodes_[node].middle]); + static_cast(inverse_permutation_[nodes_[node].middle])); } else { copy_segment_content(nodes_[node].right_child, rank, rank + right, tmp.subspan(left, right), dst.subspan(left, right)); @@ -312,126 +343,208 @@ class WaveletTreeIndex : public WaveletTreeBase> { } } - WaveletTreeIndex() = default; + static void validate_alphabet_size(std::size_t alphabet_size) { + if (alphabet_size != 0 && + alphabet_size - 1 > + static_cast(std::numeric_limits::max())) { + throw std::invalid_argument( + "Wavelet-tree alphabet does not fit its symbol type"); + } + } - public: - /** - * @param alphabet_size Size of the alphabet - * @param data Original text. Its characters are from the - * range [0, alphabet_size) - * @param build_type Either Standard or Huffman. This effects on how the - * wavelet tree builds: like segment tree on trivially sorted characters or - * like in Huffman algorithm - * - * @details - * Standard: Just calls build_node - * Huffman: Reorders characters with respect to Huffman algorithm and then - * calls build_node with specific get_middle function - * - */ - WaveletTreeIndex( - size_t alphabet_size, - std::span data, - const WaveletTreeBuildType build_type = WaveletTreeBuildType::Standard) - requires(std::same_as) - : alphabet_size_(alphabet_size), - data_size_(data.size()), - leaves_(alphabet_size_, npos) { - if (alphabet_size == 0) { - root_ = npos; - return; + static std::size_t checked_symbol_index(Symbol symbol, + std::size_t alphabet_size) { + const std::size_t index = static_cast(symbol); + if (index >= alphabet_size) { + throw std::invalid_argument( + "Wavelet-tree symbol is outside the alphabet"); } - std::vector nodes; - nodes.reserve(alphabet_size_); - std::vector nodes_structure; - nodes_structure.reserve(alphabet_size_); - - if (build_type == WaveletTreeBuildType::Standard) { - permutation_.resize(alphabet_size); - inverse_permutation_.resize(alphabet_size); - std::iota(permutation_.begin(), permutation_.end(), 0); - std::iota(inverse_permutation_.begin(), inverse_permutation_.end(), 0); - nodes_structure.resize(alphabet_size_, npos); - } else { - struct Node { - size_t size, left, right; - }; - std::vector huffman_nodes(alphabet_size_, {0, 0, 0}); - for (auto symb : data) { - huffman_nodes[symb].size++; - } + return index; + } - using elem_t = std::pair; - std::priority_queue, std::greater<>> queue; - for (size_t i = 0; i < alphabet_size_; i++) { - queue.emplace(huffman_nodes[i].size, i); - } - while (queue.size() >= 2) { - auto right = queue.top().second; - queue.pop(); - auto left = queue.top().second; - queue.pop(); - huffman_nodes.push_back( - {huffman_nodes[left].size + huffman_nodes[right].size, left + 1, - right + 1}); - queue.emplace(huffman_nodes.back().size, huffman_nodes.size() - 1); + template + void build_from_counts(std::size_t alphabet_size, + std::span symbol_counts, + ForEachSymbol&& for_each_symbol, + WaveletTreeBuildType build_type) + requires(std::same_as) + { + validate_alphabet_size(alphabet_size); + if (symbol_counts.size() != alphabet_size) { + throw std::invalid_argument( + "Wavelet-tree symbol counts must match the alphabet size"); + } + alphabet_size_ = alphabet_size; + for (const std::size_t count : symbol_counts) { + if (count > std::numeric_limits::max() - data_size_) { + throw std::length_error("Wavelet-tree input is too large"); } + data_size_ += count; + } + leaves_.assign(alphabet_size_, npos); - std::function enumerate = [&](size_t index) -> size_t { - const auto& [size, left, right] = huffman_nodes[index]; - if (left == 0 || right == 0) { - permutation_[index] = inverse_permutation_.size(); - inverse_permutation_.push_back(index); - return 1; + std::vector nodes; + std::vector nodes_structure; + if (alphabet_size_ != 0) { + nodes.reserve(alphabet_size_); + nodes_structure.reserve(alphabet_size_); + + if (build_type == WaveletTreeBuildType::Standard) { + permutation_.resize(alphabet_size_); + inverse_permutation_.resize(alphabet_size_); + std::iota(permutation_.begin(), permutation_.end(), 0); + std::iota(inverse_permutation_.begin(), inverse_permutation_.end(), 0); + nodes_structure.resize(alphabet_size_, npos); + } else { + struct HuffmanNode { + std::size_t size; + std::size_t left; + std::size_t right; + }; + std::vector huffman_nodes(alphabet_size_, {0, 0, 0}); + for (std::size_t symbol = 0; symbol < alphabet_size_; ++symbol) { + huffman_nodes[symbol].size = symbol_counts[symbol]; } - size_t ind = nodes_structure.size(), subtree = 0; - if (size > 0) { - nodes_structure.push_back(0); + + using QueueElement = std::pair; + std::priority_queue, + std::greater<>> + queue; + for (std::size_t symbol = 0; symbol < alphabet_size_; ++symbol) { + queue.emplace(huffman_nodes[symbol].size, symbol); } - subtree += enumerate(left - 1); - if (size > 0) { - nodes_structure[ind] = subtree; + while (queue.size() >= 2) { + const std::size_t right = queue.top().second; + queue.pop(); + const std::size_t left = queue.top().second; + queue.pop(); + huffman_nodes.push_back( + {huffman_nodes[left].size + huffman_nodes[right].size, left + 1, + right + 1}); + queue.emplace(huffman_nodes.back().size, huffman_nodes.size() - 1); } - subtree += enumerate(right - 1); - return subtree; - }; - permutation_.resize(alphabet_size_); - inverse_permutation_.reserve(alphabet_size_); - enumerate(huffman_nodes.size() - 1); - } + std::function enumerate = + [&](std::size_t index) -> std::size_t { + const auto& [size, left, right] = huffman_nodes[index]; + if (left == 0 || right == 0) { + permutation_[index] = inverse_permutation_.size(); + inverse_permutation_.push_back(index); + return 1; + } + const std::size_t node = nodes_structure.size(); + std::size_t subtree = 0; + if (size > 0) { + nodes_structure.push_back(0); + } + subtree += enumerate(left - 1); + if (size > 0) { + nodes_structure[node] = subtree; + } + subtree += enumerate(right - 1); + return subtree; + }; - std::vector prefix_sum(alphabet_size + 1); - for (auto symbol : data) { - prefix_sum[permutation_[symbol] + 1]++; - } - for (size_t i = 0; i < alphabet_size_; i++) { - prefix_sum[i + 1] += prefix_sum[i]; - } + permutation_.resize(alphabet_size_); + inverse_permutation_.reserve(alphabet_size_); + enumerate(huffman_nodes.size() - 1); + } - root_ = build_node( - 0, alphabet_size_, npos, - [&](node_index_t node) { return nodes_structure[node]; }, prefix_sum, - nodes); - for (auto symbol : data) { - auto index = permutation_[symbol]; + std::vector prefix_sum(alphabet_size_ + 1); + for (std::size_t symbol = 0; symbol < alphabet_size_; ++symbol) { + prefix_sum[permutation_[symbol] + 1] = symbol_counts[symbol]; + } + std::partial_sum(prefix_sum.begin(), prefix_sum.end(), + prefix_sum.begin()); + root_ = build_node( + 0, alphabet_size_, npos, + [&](node_index_t node) { return nodes_structure[node]; }, prefix_sum, + nodes); + } + + std::vector actual_counts(alphabet_size_); + for_each_symbol([&](Symbol symbol) { + const std::size_t original = checked_symbol_index(symbol, alphabet_size_); + if (actual_counts[original] == std::numeric_limits::max()) { + throw std::length_error("Wavelet-tree symbol count is too large"); + } + ++actual_counts[original]; + const std::size_t permuted = permutation_[original]; for (node_index_t current = root_; current != npos;) { auto& node = nodes[current]; - bool go_right = index >= node.middle; + const bool go_right = permuted >= node.middle; node.stream.write_bit(go_right); - if (go_right) { - current = node.right_child; - } else { - current = node.left_child; - } + current = go_right ? node.right_child : node.left_child; } + }); + if (!std::ranges::equal(actual_counts, symbol_counts)) { + throw std::invalid_argument( + "Wavelet-tree emitted symbols do not match their counts"); } + nodes_.reserve(nodes.size()); for (auto& node : nodes) { nodes_.emplace_back(std::move(node)); } } + WaveletTreeIndex() = default; + + public: + using symbol_type = Symbol; + + /** + * @brief Construct from a contiguous sequence of typed symbols. + * @param alphabet_size Dense alphabet size; every symbol must be smaller. + * @param data Input symbols retained only for the duration of construction. + * @param build_type Standard or Huffman-shaped construction. + * @throws std::invalid_argument if the alphabet or a symbol is invalid. + */ + WaveletTreeIndex( + std::size_t alphabet_size, + std::span data, + const WaveletTreeBuildType build_type = WaveletTreeBuildType::Standard) + requires(std::same_as) + { + validate_alphabet_size(alphabet_size); + std::vector counts(alphabet_size); + for (const Symbol symbol : data) { + ++counts[checked_symbol_index(symbol, alphabet_size)]; + } + build_from_counts( + alphabet_size, counts, + [&](auto&& emit) { + for (const Symbol symbol : data) { + emit(symbol); + } + }, + build_type); + } + + /** + * @brief Construct from counts and one streamed pass over the symbols. + * @details @p for_each_symbol is invoked exactly once with a consumer that + * accepts one `Symbol`. Emitted symbols must exactly match @p symbol_counts; + * this permits callers to scan a replayable source once for counts and once + * for construction without materializing the sequence. + * @param alphabet_size Dense alphabet size. + * @param symbol_counts Count for every symbol in alphabet order. + * @param for_each_symbol Callable accepting the construction consumer. + * @param build_type Standard or Huffman-shaped construction. + * @throws std::invalid_argument for invalid or inconsistent input. + */ + template + WaveletTreeIndex( + std::size_t alphabet_size, + std::span symbol_counts, + ForEachSymbol&& for_each_symbol, + const WaveletTreeBuildType build_type = WaveletTreeBuildType::Standard) + requires(std::same_as) + { + build_from_counts(alphabet_size, symbol_counts, + std::forward(for_each_symbol), build_type); + } + /** * @brief Rank of specified symbol up to position pos (exclusive) * @@ -440,14 +553,15 @@ class WaveletTreeIndex : public WaveletTreeBase> { * @return Number of specified symbols in [0, pos) * */ - size_t rank_impl(uint64_t symbol, size_t pos) const { - if (symbol >= alphabet_size_) [[unlikely]] { + size_t rank_impl(Symbol symbol, size_t pos) const { + std::size_t symbol_index = static_cast(symbol); + if (symbol_index >= alphabet_size_) [[unlikely]] { return 0; } - symbol = permutation_[symbol]; + symbol_index = permutation_[symbol_index]; for (node_index_t current = root_; current != npos;) { const WaveletNode& node = nodes_[current]; - if (symbol < node.middle) { + if (symbol_index < node.middle) { pos = node.data.rank0(pos); current = node.left_child; } else { @@ -466,15 +580,16 @@ class WaveletTreeIndex : public WaveletTreeBase> { * @return Symbol index, or size() if rank is out of range * */ - size_t select_impl(uint64_t symbol, size_t rank) const { - if (symbol >= alphabet_size_ || data_size_ == 0) [[unlikely]] { + size_t select_impl(Symbol symbol, size_t rank) const { + std::size_t symbol_index = static_cast(symbol); + if (symbol_index >= alphabet_size_ || data_size_ == 0) [[unlikely]] { return data_size_; } - symbol = permutation_[symbol]; - node_index_t current = leaves_[symbol]; + symbol_index = permutation_[symbol_index]; + node_index_t current = leaves_[symbol_index]; for (; current != npos; current = nodes_[current].parent) { const WaveletNode& node = nodes_[current]; - if (symbol < node.middle) { + if (symbol_index < node.middle) { rank = node.data.select0(rank) + 1; } else { rank = node.data.select(rank) + 1; @@ -494,18 +609,24 @@ class WaveletTreeIndex : public WaveletTreeBase> { * through the node storage. A deserialized view does not consult or retain * its `BinaryReader`. The current implementation materializes the requested * output and an equally sized temporary buffer, for peak auxiliary and - * result storage of two `uint64_t` values per returned symbol. + * result storage of two `Symbol` values per returned symbol. * */ - std::vector get_segment_impl(size_t begin, size_t end) const { + std::vector get_segment_impl(size_t begin, size_t end) const { if (alphabet_size_ == 0 || data_size_ == 0 || begin >= end) [[unlikely]] { return {}; } - auto length = static_cast(end - begin); - std::vector result(2 * length); - copy_segment_content(root_, begin, end, - std::span{result.begin(), result.begin() + length}, - std::span{result.begin() + length, result.end()}); + const std::size_t length = end - begin; + if (root_ == npos) [[unlikely]] { + return std::vector( + length, static_cast(inverse_permutation_.front())); + } + if (length > std::vector().max_size() / 2) { + throw std::length_error("Wavelet-tree segment is too large"); + } + std::vector result(2 * length); + copy_segment_content(root_, begin, end, std::span(result).first(length), + std::span(result).subspan(length)); result.resize(length); return result; } @@ -530,7 +651,7 @@ class WaveletTreeIndex : public WaveletTreeBase> { const std::size_t artifact_begin = writer.size_bytes(); detail::write_magic(writer, kSerializationMagic); writer.write_u32(kSerializationVersion); - writer.write_u32(0); + writer.write_u32(std::numeric_limits::digits); const std::size_t artifact_size_position = writer.write_u64_placeholder(); writer.write_size(alphabet_size_); @@ -572,7 +693,7 @@ class WaveletTreeIndex : public WaveletTreeBase> { * structurally inconsistent metadata. * @throws std::length_error when an encoded count is not representable. */ - static WaveletTreeIndex deserialize( + static WaveletTreeIndex deserialize( BinaryReader& reader, DeserializationValidation validation = DeserializationValidation::kQuick) { @@ -586,7 +707,7 @@ class WaveletTreeIndex : public WaveletTreeBase> { const std::size_t available_size = candidate.remaining(); detail::require_magic(candidate, kSerializationMagic); if (candidate.read_u32() != kSerializationVersion || - candidate.read_u32() != 0) { + candidate.read_u32() != std::numeric_limits::digits) { throw std::invalid_argument( "Incompatible serialized wavelet-tree artifact"); } @@ -595,8 +716,9 @@ class WaveletTreeIndex : public WaveletTreeBase> { BinaryReader payload = candidate.read_subreader(artifact_size - kSerializationHeaderBytes); - WaveletTreeIndex result; + WaveletTreeIndex result; result.alphabet_size_ = payload.read_size(); + result.validate_alphabet_size(result.alphabet_size_); result.data_size_ = payload.read_size(); result.root_ = payload.read_size(); const std::size_t node_count = payload.read_size(); @@ -677,7 +799,7 @@ class WaveletTreeIndex : public WaveletTreeBase> { * @param data Input bytes, advanced only after successful validation. * @param validation Quick structural or full bitvector-derived validation. */ - static WaveletTreeIndex deserialize( + static WaveletTreeIndex deserialize( std::span& data, DeserializationValidation validation = DeserializationValidation::kQuick) { @@ -688,7 +810,10 @@ class WaveletTreeIndex : public WaveletTreeBase> { } }; -using WaveletTree = WaveletTreeIndex; -using WaveletTreeView = WaveletTreeIndex; +template +using WaveletTree = WaveletTreeIndex; + +template +using WaveletTreeView = WaveletTreeIndex; } // namespace pixie diff --git a/scripts/coverage_report.sh b/scripts/coverage_report.sh index 5f19cda..5c6ab06 100755 --- a/scripts/coverage_report.sh +++ b/scripts/coverage_report.sh @@ -3,15 +3,16 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="${ROOT_DIR}/build/coverage" +JOBS="${PIXIE_COVERAGE_JOBS:-$(nproc)}" cmake --preset coverage -cmake --build --preset coverage +cmake --build --preset coverage --parallel "${JOBS}" find "${BUILD_DIR}" -name "*.gcda" -delete find "${BUILD_DIR}" -name "*.gcov" -delete rm -f "${BUILD_DIR}/coverage.txt" "${BUILD_DIR}/gcov_files.txt" -ctest --preset coverage +ctest --preset coverage --parallel "${JOBS}" cd "${BUILD_DIR}" find . -name "*.gcda" > gcov_files.txt diff --git a/src/benchmarks/file_archive_benchmarks.cpp b/src/benchmarks/file_archive_benchmarks.cpp new file mode 100644 index 0000000..bc49a1b --- /dev/null +++ b/src/benchmarks/file_archive_benchmarks.cpp @@ -0,0 +1,87 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector Bytes(std::string_view value) { + const auto bytes = std::as_bytes(std::span(value.data(), value.size())); + return {bytes.begin(), bytes.end()}; +} + +std::vector MakeFiles(std::size_t count) { + std::vector files; + files.reserve(count); + const std::vector content = Bytes(std::string(255, 'x') + "\n"); + for (std::size_t index = 0; index < count; ++index) { + files.push_back( + {.path = "src/file-" + std::to_string(index), .content = content}); + } + return files; +} + +pixie::FileArchiveView MakeView(const pixie::FileArchive& archive, + std::vector& storage) { + pixie::VectorOutputSink sink; + pixie::BinaryWriter writer(sink); + archive.serialize(writer); + writer.finish(); + storage = sink.take(); + pixie::BinaryReader reader(storage); + return pixie::FileArchiveView::deserialize(reader); +} + +void BM_FileArchiveFind(benchmark::State& state) { + const std::size_t count = static_cast(state.range(0)); + const pixie::FileArchive archive(MakeFiles(count)); + std::vector storage; + const pixie::FileArchiveView view = MakeView(archive, storage); + std::vector paths; + paths.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + paths.push_back("src/file-" + std::to_string(index)); + } + + std::size_t query = 0; + for (auto _ : state) { + benchmark::DoNotOptimize(view.find(paths[query])); + query = (query + 7919) % count; + } + state.SetItemsProcessed(state.iterations()); +} + +void BM_FileArchiveExtractLines(benchmark::State& state) { + std::string content; + for (std::size_t line = 0; line < 1U << 16U; ++line) { + content.append("0123456789abcdef\n"); + } + const pixie::FileArchive archive( + {{.path = "source.cpp", .content = Bytes(content)}}); + std::vector storage; + const pixie::FileArchiveView view = MakeView(archive, storage); + + std::size_t begin = 0; + const std::size_t line_count = view.entry(0).line_count; + const std::size_t extracted_lines = static_cast(state.range(0)); + for (auto _ : state) { + benchmark::DoNotOptimize( + view.extract_lines(0, begin, begin + extracted_lines)); + begin = (begin + 7919) % (line_count - extracted_lines + 1); + } + state.SetItemsProcessed(state.iterations() * + static_cast(extracted_lines)); +} + +BENCHMARK(BM_FileArchiveFind)->RangeMultiplier(8)->Range(64, 1U << 18U); +BENCHMARK(BM_FileArchiveExtractLines)->Arg(1)->Arg(8)->Arg(64)->Arg(512); + +} // namespace + +BENCHMARK_MAIN(); diff --git a/src/benchmarks/serialization_benchmarks.cpp b/src/benchmarks/serialization_benchmarks.cpp index 9b6c877..4ddf81d 100644 --- a/src/benchmarks/serialization_benchmarks.cpp +++ b/src/benchmarks/serialization_benchmarks.cpp @@ -487,7 +487,7 @@ void BM_RmqDeserializeImpl(benchmark::State& state) { void BM_WaveletTreeSerialize(benchmark::State& state) { const std::size_t symbol_count = static_cast(state.range(0)); const std::vector symbols = make_symbols(symbol_count); - const pixie::WaveletTree index(kWaveletAlphabetSize, symbols); + const pixie::WaveletTree index(kWaveletAlphabetSize, symbols); const std::vector artifact = serialize_to_vector(index); std::vector destination(artifact.size()); std::vector staging(kDefaultStagingBytes); @@ -500,12 +500,13 @@ template void BM_WaveletTreeDeserializeViewImpl(benchmark::State& state) { const std::size_t symbol_count = static_cast(state.range(0)); const std::vector symbols = make_symbols(symbol_count); - const pixie::WaveletTree index(kWaveletAlphabetSize, symbols); + const pixie::WaveletTree index(kWaveletAlphabetSize, symbols); const std::vector serialized = serialize_to_vector(index); const AlignedArtifact artifact(as_const_span(serialized)); deserialize_iterations( state, artifact.bytes(), [](pixie::BinaryReader& reader) { - return pixie::WaveletTreeView::deserialize(reader, Validation); + return pixie::WaveletTreeView::deserialize(reader, + Validation); }); set_artifact_counters(state, symbol_count, artifact.bytes().size()); } diff --git a/src/benchmarks/wavelet_tree_benchmarks.cpp b/src/benchmarks/wavelet_tree_benchmarks.cpp index 35ae3d4..5b98295 100644 --- a/src/benchmarks/wavelet_tree_benchmarks.cpp +++ b/src/benchmarks/wavelet_tree_benchmarks.cpp @@ -4,7 +4,7 @@ #include -using pixie::WaveletTree; +using WaveletTree = pixie::WaveletTree; static void BM_WaveletTreeSelect(benchmark::State& state) { size_t data_size = state.range(0), alphabet_size = 1024, query = data_size; @@ -94,7 +94,7 @@ static void BM_WaveletTreeViewSelect(benchmark::State& state) { state.ResumeTiming(); - auto view_tree = pixie::WaveletTreeView::deserialize(reader); + auto view_tree = pixie::WaveletTreeView::deserialize(reader); benchmark::DoNotOptimize(view_tree); for (size_t i = 0; i < query; i++) { @@ -128,7 +128,7 @@ static void BM_WaveletTreeViewRank(benchmark::State& state) { state.ResumeTiming(); - auto view_tree = pixie::WaveletTreeView::deserialize(reader); + auto view_tree = pixie::WaveletTreeView::deserialize(reader); benchmark::DoNotOptimize(view_tree); for (size_t i = 0; i < query; i++) { @@ -161,7 +161,7 @@ static void BM_WaveletTreeViewSegment(benchmark::State& state) { state.ResumeTiming(); - auto view_tree = pixie::WaveletTreeView::deserialize(reader); + auto view_tree = pixie::WaveletTreeView::deserialize(reader); benchmark::DoNotOptimize(view_tree); for (size_t i = 0; i < query; i++) { diff --git a/src/tests/file_archive_tests.cpp b/src/tests/file_archive_tests.cpp new file mode 100644 index 0000000..4a0c8ba --- /dev/null +++ b/src/tests/file_archive_tests.cpp @@ -0,0 +1,358 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::vector Bytes(std::string_view value) { + const auto bytes = std::as_bytes(std::span(value.data(), value.size())); + return {bytes.begin(), bytes.end()}; +} + +std::string String(std::span bytes) { + return {reinterpret_cast(bytes.data()), bytes.size()}; +} + +std::vector Serialize(const pixie::FileArchive& archive) { + pixie::VectorOutputSink sink; + pixie::BinaryWriter writer(sink); + archive.serialize(writer); + writer.finish(); + return sink.take(); +} + +void OverwriteU32(std::vector& bytes, + std::size_t offset, + std::uint32_t value) { + for (std::size_t byte = 0; byte < sizeof(value); ++byte) { + bytes[offset + byte] = + static_cast((value >> (8 * byte)) & 0xffU); + } +} + +void OverwriteU64(std::vector& bytes, + std::size_t offset, + std::uint64_t value) { + for (std::size_t byte = 0; byte < sizeof(value); ++byte) { + bytes[offset + byte] = + static_cast((value >> (8 * byte)) & 0xffU); + } +} + +std::vector Sources() { + return {{.path = "src/main.cpp", + .content = Bytes("zero\none\n\ntwo"), + .type = pixie::FileArchiveEntryType::kRegular, + .mode = 0755}, + {.path = "assets/data.bin", + .content = {std::byte{0xff}, std::byte{0}, std::byte{'\n'}}, + .type = pixie::FileArchiveEntryType::kRegular, + .mode = 0644}, + {.path = "current", + .content = Bytes("src/main.cpp"), + .type = pixie::FileArchiveEntryType::kSymlink, + .mode = 0777}}; +} + +} // namespace + +static_assert(!std::is_copy_constructible_v); +static_assert(!std::is_copy_assignable_v); +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + +TEST(FileArchiveTest, FindsAndExtractsSortedEntries) { + for (const auto build_type : {pixie::WaveletTreeBuildType::Standard, + pixie::WaveletTreeBuildType::Huffman}) { + const pixie::FileArchive archive(Sources(), build_type); + ASSERT_EQ(archive.size(), 3u); + ASSERT_TRUE(archive.find("src/main.cpp").has_value()); + EXPECT_FALSE(archive.find("missing").has_value()); + + const std::size_t source = *archive.find("src/main.cpp"); + const auto source_metadata = archive.entry(source); + EXPECT_EQ(source_metadata.line_count, 4u); + EXPECT_TRUE(source_metadata.is_text); + EXPECT_EQ(source_metadata.mode, 0755u); + EXPECT_EQ(String(archive.extract(source)), "zero\none\n\ntwo"); + EXPECT_EQ(String(archive.extract_lines(source, 0, 2)), "zero\none\n"); + EXPECT_EQ(String(archive.extract_lines(source, 1, 1)), ""); + EXPECT_EQ(String(archive.extract_lines(source, 2, 3)), "\n"); + EXPECT_EQ(String(archive.extract_lines(source, 4, 4)), ""); + + const std::size_t binary = *archive.find("assets/data.bin"); + EXPECT_FALSE(archive.entry(binary).is_text); + EXPECT_THROW(archive.extract_lines(binary, 0, 1), std::invalid_argument); + EXPECT_EQ(archive.extract(binary), Sources()[1].content); + + const std::size_t symlink = *archive.find("current"); + EXPECT_EQ(archive.entry(symlink).type, + pixie::FileArchiveEntryType::kSymlink); + EXPECT_EQ(String(archive.extract(symlink)), "src/main.cpp"); + EXPECT_THROW(archive.extract_lines(symlink, 0, 0), std::invalid_argument); + } +} + +TEST(FileArchiveTest, RetainsInternalViewsAcrossMoves) { + std::optional source(std::in_place, Sources()); + pixie::FileArchive moved(std::move(*source)); + source.reset(); + + const std::size_t source_index = *moved.find("src/main.cpp"); + EXPECT_EQ(String(moved.extract_lines(source_index, 1, 4)), "one\n\ntwo"); + + pixie::FileArchive assigned( + {{.path = "placeholder", .content = Bytes("unused")}}); + std::optional assignment_source(std::in_place, Sources()); + assigned = std::move(*assignment_source); + assignment_source.reset(); + + const std::size_t assigned_index = *assigned.find("src/main.cpp"); + EXPECT_EQ(String(assigned.extract_lines(assigned_index, 0, 2)), + "zero\none\n"); +} + +TEST(FileArchiveTest, SerializesOwningAndReadOnlyQueries) { + for (const auto build_type : {pixie::WaveletTreeBuildType::Standard, + pixie::WaveletTreeBuildType::Huffman}) { + const pixie::FileArchive archive(Sources(), build_type); + const std::vector bytes = Serialize(archive); + pixie::BinaryReader reader(bytes); + const pixie::FileArchiveView quick = pixie::FileArchiveView::deserialize( + reader, pixie::DeserializationValidation::kQuick); + EXPECT_TRUE(reader.empty()); + EXPECT_EQ(quick.build_type(), build_type); + const std::size_t source = *quick.find("src/main.cpp"); + EXPECT_EQ(String(quick.extract_lines(source, 1, 4)), "one\n\ntwo"); + + pixie::BinaryReader full_reader(bytes); + const pixie::FileArchiveView full = pixie::FileArchiveView::deserialize( + full_reader, pixie::DeserializationValidation::kFull); + EXPECT_EQ(String(full.extract(*full.find("current"))), "src/main.cpp"); + } +} + +TEST(FileArchiveTest, SupportsEmptyAndAllZeroContents) { + std::vector sources = { + {.path = "empty", .content = {}}, + {.path = "zero", .content = std::vector(3, std::byte{0})}}; + const pixie::FileArchive archive(std::move(sources)); + EXPECT_TRUE(archive.extract(*archive.find("empty")).empty()); + EXPECT_EQ(archive.extract(*archive.find("zero")), + std::vector(3, std::byte{0})); +} + +TEST(FileArchiveTest, StreamsTypedBytesAcrossTwoBuildPasses) { + std::vector sources = { + {.path = "text", .type = pixie::FileArchiveEntryType::kRegular}, + {.path = "all-bytes", .type = pixie::FileArchiveEntryType::kRegular}}; + std::array calls{}; + const auto read_source = [&](const auto& source, auto&& consume) { + if (source.path == "text") { + ++calls[0]; + const std::vector bytes = Bytes("x\xe2\x82\xac\n"); + consume(std::span(bytes).first(2)); + consume(std::span(bytes).subspan(2)); + return; + } + ++calls[1]; + std::vector bytes(256); + for (std::size_t value = 0; value < bytes.size(); ++value) { + bytes[value] = static_cast(value); + } + consume(std::span(bytes)); + }; + const pixie::FileArchive archive(std::move(sources), read_source, + pixie::WaveletTreeBuildType::Huffman); + + EXPECT_EQ(calls, (std::array{2, 2})); + EXPECT_EQ(String(archive.extract(*archive.find("text"))), "x\xe2\x82\xac\n"); + EXPECT_TRUE(archive.entry(*archive.find("text")).is_text); + EXPECT_EQ(archive.entry(*archive.find("text")).line_count, 1u); + EXPECT_EQ(archive.extract(*archive.find("all-bytes")).size(), 256u); + + const std::vector artifact = Serialize(archive); + pixie::BinaryReader reader(artifact); + const auto view = pixie::FileArchiveView::deserialize( + reader, pixie::DeserializationValidation::kFull); + EXPECT_TRUE(reader.empty()); + EXPECT_EQ(view.logical_size_bytes(), 261u); +} + +TEST(FileArchiveTest, RejectsContentChangedBetweenStreamingPasses) { + std::size_t pass = 0; + EXPECT_THROW( + (pixie::FileArchive( + {{.path = "changed", .type = pixie::FileArchiveEntryType::kRegular}}, + [&](const auto&, auto&& consume) { + const std::vector bytes = + Bytes(pass++ == 0 ? "ab" : "ba"); + consume(std::span(bytes)); + })), + std::invalid_argument); + EXPECT_EQ(pass, 2u); +} + +TEST(FileArchiveTest, SerializesAnEmptyArchiveWithoutATerminalSymbol) { + const pixie::FileArchive archive( + std::vector{}, + [](const auto&, auto&&) { FAIL() << "empty archive read a source"; }); + EXPECT_EQ(archive.logical_size_bytes(), 0u); + const std::vector artifact = Serialize(archive); + pixie::BinaryReader reader(artifact); + const auto view = pixie::FileArchiveView::deserialize( + reader, pixie::DeserializationValidation::kFull); + EXPECT_TRUE(view.empty()); + EXPECT_EQ(view.logical_size_bytes(), 0u); +} + +TEST(FileArchiveTest, RejectsInvalidSourcesAndRanges) { + EXPECT_THROW(pixie::FileArchive({{.path = "", .content = {}}}), + std::invalid_argument); + EXPECT_THROW(pixie::FileArchive({{.path = "same", .content = {}}, + {.path = "same", .content = {}}}), + std::invalid_argument); + + const pixie::FileArchive archive(Sources()); + const std::size_t source = *archive.find("src/main.cpp"); + EXPECT_THROW(archive.extract_lines(source, 3, 2), std::out_of_range); + EXPECT_THROW(archive.extract_lines(source, 0, 5), std::out_of_range); + EXPECT_THROW(archive.entry(archive.size()), std::out_of_range); +} + +TEST(FileArchiveTest, ValidatesUtf8PathsAndEntryTypes) { + const auto make_archive = [](std::string path) { + return pixie::FileArchive({{.path = std::move(path), .content = {}}}); + }; + + EXPECT_NO_THROW(make_archive("ascii-\xc2\xa2-\xe2\x82\xac-\xf0\x9f\x98\x80")); + EXPECT_THROW(make_archive(std::string("nul\0path", 8)), + std::invalid_argument); + EXPECT_THROW(make_archive("\x80"), std::invalid_argument); + EXPECT_THROW(make_archive("\xc2x"), std::invalid_argument); + EXPECT_THROW(make_archive("\xe0\x80\x80"), std::invalid_argument); + EXPECT_THROW(make_archive("\xed\xa0\x80"), std::invalid_argument); + EXPECT_THROW(make_archive("\xf4\x90\x80\x80"), std::invalid_argument); + EXPECT_THROW(make_archive("\xf0"), std::invalid_argument); + EXPECT_THROW(pixie::FileArchive( + {{.path = "invalid-type", + .content = {}, + .type = static_cast(2)}}), + std::invalid_argument); +} + +TEST(FileArchiveTest, RejectsMalformedMetadataTransactionally) { + const pixie::FileArchive archive(Sources()); + const std::vector valid = Serialize(archive); + const auto expect_rejected = + [](std::vector bytes, + pixie::DeserializationValidation validation = + pixie::DeserializationValidation::kQuick) { + pixie::BinaryReader reader(bytes); + EXPECT_THROW(pixie::FileArchiveView::deserialize(reader, validation), + std::invalid_argument); + EXPECT_EQ(reader.position(), 0u); + }; + + auto bad_version = valid; + OverwriteU32(bad_version, 8, 6); + expect_rejected(std::move(bad_version)); + + auto bad_header_reserved = valid; + OverwriteU32(bad_header_reserved, 12, 1); + expect_rejected(std::move(bad_header_reserved)); + + auto bad_build_type = valid; + OverwriteU32(bad_build_type, 24, 2); + expect_rejected(std::move(bad_build_type)); + + auto bad_payload_reserved = valid; + OverwriteU32(bad_payload_reserved, 28, 1); + expect_rejected(std::move(bad_payload_reserved)); + + auto bad_section_size = valid; + OverwriteU64(bad_section_size, 48, 0); + expect_rejected(std::move(bad_section_size)); + + constexpr std::size_t kFirstRecord = 72; + auto bad_type = valid; + bad_type[kFirstRecord + 52] = std::byte{2}; + expect_rejected(std::move(bad_type)); + + auto bad_text_flag = valid; + bad_text_flag[kFirstRecord + 53] = std::byte{2}; + expect_rejected(std::move(bad_text_flag)); + + auto bad_record_reserved = valid; + bad_record_reserved[kFirstRecord + 54] = std::byte{1}; + expect_rejected(std::move(bad_record_reserved)); + + const std::size_t padding_begin = + 72 + archive.file_table_bytes() + archive.path_storage_bytes(); + ASSERT_LT(padding_begin, archive.metadata_bytes()); + auto bad_padding = valid; + bad_padding[padding_begin] = std::byte{1}; + expect_rejected(std::move(bad_padding)); + + auto bad_logical_size = valid; + OverwriteU64(bad_logical_size, 40, std::numeric_limits::max()); + expect_rejected(std::move(bad_logical_size)); + + auto bad_path_bounds = valid; + OverwriteU64(bad_path_bounds, kFirstRecord + 8, + std::numeric_limits::max()); + expect_rejected(std::move(bad_path_bounds)); + + auto empty_path = valid; + OverwriteU64(empty_path, kFirstRecord + 8, 0); + expect_rejected(std::move(empty_path), + pixie::DeserializationValidation::kFull); + + auto wrong_derived_text = valid; + wrong_derived_text[kFirstRecord + 53] = std::byte{1}; + expect_rejected(std::move(wrong_derived_text), + pixie::DeserializationValidation::kFull); +} + +TEST(FileArchiveTest, RejectsUnalignedSerializationAndDeserialization) { + const pixie::FileArchive archive(Sources()); + pixie::VectorOutputSink sink; + pixie::BinaryWriter writer(sink); + writer.write_u8(0); + EXPECT_THROW(archive.serialize(writer), std::invalid_argument); + + const std::vector valid = Serialize(archive); + std::vector unaligned(valid.size() + 1); + std::ranges::copy(valid, unaligned.begin() + 1); + pixie::BinaryReader reader(std::span(unaligned).subspan(1)); + EXPECT_THROW(pixie::FileArchiveView::deserialize(reader), + std::invalid_argument); + EXPECT_EQ(reader.position(), 0u); +} + +TEST(FileArchiveTest, RejectsTrailingAndTruncatedArtifacts) { + const pixie::FileArchive archive(Sources()); + std::vector bytes = Serialize(archive); + bytes.push_back(std::byte{0}); + pixie::BinaryReader trailing(bytes); + const auto view = pixie::FileArchiveView::deserialize(trailing); + EXPECT_EQ(view.size(), 3u); + EXPECT_FALSE(trailing.empty()); + + bytes.resize(16); + pixie::BinaryReader truncated(bytes); + EXPECT_THROW(pixie::FileArchiveView::deserialize(truncated), + std::invalid_argument); +} diff --git a/src/tests/rank_select_tests.cpp b/src/tests/rank_select_tests.cpp index d89cc7d..eb6c7b2 100644 --- a/src/tests/rank_select_tests.cpp +++ b/src/tests/rank_select_tests.cpp @@ -34,20 +34,14 @@ void overwrite_u32(std::vector& bytes, } } -void overwrite_u16(std::vector& bytes, - std::size_t offset, - std::uint16_t value) { - for (std::size_t byte = 0; byte < sizeof(value); ++byte) { - bytes[offset + byte] = - static_cast((value >> (byte * 8)) & 0xffu); - } -} - struct RankSelectSampleLayout { std::size_t select1_begin; std::size_t select1_count; std::size_t select0_begin; std::size_t select0_count; + std::size_t super_bytes; + std::size_t basic_bytes; + std::size_t sample_bytes; std::size_t storage_offset; }; @@ -60,14 +54,14 @@ RankSelectSampleLayout locate_rank_select_samples( result.select1_count = reader.read_size(); result.select0_begin = reader.read_size(); result.select0_count = reader.read_size(); - reader.skip(2 * sizeof(std::uint32_t) + 8 * sizeof(std::uint64_t) + - 32 * sizeof(std::uint16_t)); - for (std::size_t storage = 0; storage < 2; ++storage) { - reader.skip(reader.read_size()); - } - const std::size_t sample_bytes = reader.read_size(); + reader.skip(2 * sizeof(std::uint32_t)); + result.super_bytes = reader.read_size(); + reader.skip(result.super_bytes); + result.basic_bytes = reader.read_size(); + reader.skip(result.basic_bytes); + result.sample_bytes = reader.read_size(); result.storage_offset = reader.position(); - reader.skip(sample_bytes); + reader.skip(result.sample_bytes); return result; } @@ -144,6 +138,20 @@ TEST(RankSelectSupportTest, AcceptsStorageSourceWithoutCopying) { EXPECT_EQ(support[2], 0); } +TEST(RankSelectSupportTest, AcceptsPartialWordAlignedStorageSource) { + pixie::AlignedStorage storage(1); + storage.writable_bytes()[0] = std::byte{1}; + + const pixie::RankSelectSupport support(storage, 1); + + EXPECT_EQ(support.size(), 1u); + EXPECT_EQ(support.rank(1), 1u); + EXPECT_EQ(support.select(1), 0u); + + const pixie::RankSelectSupport clamped(storage, 65); + EXPECT_EQ(clamped.size(), 8u); +} + TEST(RankSelectSupportTest, OwningMetadataDeserializationRoundTrips) { constexpr std::size_t kBitCount = 4097; std::vector words((kBitCount + 63) / 64); @@ -179,6 +187,32 @@ TEST(RankSelectSupportTest, OwningMetadataDeserializationRoundTrips) { } } +TEST(RankSelectSupportTest, SerializesOnlySimdRoundedBasicBlockMetadata) { + constexpr std::array bit_counts = { + 0, 1, 32 * 512, 32 * 512 + 1, 65536, 65537}; + for (const std::size_t bit_count : bit_counts) { + SCOPED_TRACE(bit_count); + const std::vector words((bit_count + 63) / 64); + const pixie::RankSelectSupport<> support( + words, bit_count, pixie::RankSelectSupport<>::SelectSupport::kNone); + pixie::VectorOutputSink output; + pixie::BinaryWriter writer(output); + support.serialize(writer); + writer.finish(); + const std::vector artifact = output.take(); + const RankSelectSampleLayout layout = locate_rank_select_samples(artifact); + const std::size_t data_superblocks = + bit_count == 0 ? 0 : 1 + (bit_count - 1) / 65536; + const std::size_t data_basicblocks = + bit_count == 0 ? 0 : 1 + (bit_count - 1) / 512; + const std::size_t stored_basicblocks = (data_basicblocks + 31) / 32 * 32; + EXPECT_EQ(layout.super_bytes, + (data_superblocks + 1) * sizeof(std::uint64_t)); + EXPECT_EQ(layout.basic_bytes, stored_basicblocks * sizeof(std::uint16_t)); + EXPECT_EQ(layout.sample_bytes, 0u); + } +} + TEST(RankSelectSupportTest, QuickAndFullValidationAcceptValidMetadata) { constexpr std::size_t kBitCount = 65537; std::vector words((kBitCount + 63) / 64); @@ -380,15 +414,6 @@ TEST(RankSelectSupportTest, overwrite_u32(invalid_boolean, 7 * sizeof(std::uint64_t) + sizeof(std::uint32_t), 2); expect_rejected(std::move(invalid_boolean)); - - auto invalid_super_delta = valid; - overwrite_u64(invalid_super_delta, 9 * sizeof(std::uint64_t), 0); - expect_rejected(std::move(invalid_super_delta)); - - auto invalid_basic_delta = valid; - overwrite_u16(invalid_basic_delta, - 16 * sizeof(std::uint64_t) + sizeof(std::uint16_t), 0); - expect_rejected(std::move(invalid_basic_delta)); } } // namespace diff --git a/src/tests/rmq_tests.cpp b/src/tests/rmq_tests.cpp index 19e2ec0..1973048 100644 --- a/src/tests/rmq_tests.cpp +++ b/src/tests/rmq_tests.cpp @@ -300,8 +300,7 @@ static RmqArtifactLayout locate_rmq_artifact( result.has_rank_index = reader.position(); const bool has_rank_index = reader.read_u8() != 0; if (has_rank_index) { - reader.skip(7 * sizeof(std::uint64_t) + 2 * sizeof(std::uint32_t) + - 8 * sizeof(std::uint64_t) + 32 * sizeof(std::uint16_t)); + reader.skip(7 * sizeof(std::uint64_t) + 2 * sizeof(std::uint32_t)); for (std::size_t storage = 0; storage < 3; ++storage) { reader.skip(reader.read_size()); } @@ -496,7 +495,7 @@ TEST(RmqSerializationTest, RejectsCorruptionWithoutAdvancingInput) { expect_rejected(std::move(bad_magic)); auto bad_version = valid; - overwrite(bad_version, 8, std::uint32_t{2}); + overwrite(bad_version, 8, std::uint32_t{5}); expect_rejected(std::move(bad_version)); auto bad_leaf_size = valid; diff --git a/src/tests/storage_tests.cpp b/src/tests/storage_tests.cpp index 22b5528..1799158 100644 --- a/src/tests/storage_tests.cpp +++ b/src/tests/storage_tests.cpp @@ -84,7 +84,9 @@ TEST(StorageSerializationTest, OwningStorageAndViewSerializeIdentically) { storage.view().serialize(view_writer); owning_writer.finish(); view_writer.finish(); - EXPECT_EQ(owning_output.take(), view_output.take()); + const std::vector owning_bytes = owning_output.take(); + EXPECT_EQ(owning_bytes.size(), sizeof(std::uint64_t) + 1); + EXPECT_EQ(owning_bytes, view_output.take()); } TEST(StorageSerializationTest, ReadOnlyViewRoundTripsAndAdvancesInput) { @@ -103,18 +105,39 @@ TEST(StorageSerializationTest, ReadOnlyViewRoundTripsAndAdvancesInput) { TEST(AlignedStorageTest, PadsResizesAndProvidesWritableStorage) { pixie::AlignedStorage storage(1); - EXPECT_EQ(storage.size_bytes(), pixie::kAlignedStorageLineBytes); - EXPECT_EQ(storage.size_bits(), pixie::kAlignedStorageLineBits); + EXPECT_EQ(storage.size_bytes(), 1u); + EXPECT_EQ(storage.logical_size_bytes(), 1u); + EXPECT_EQ(storage.size_bits(), 8u); + EXPECT_EQ(storage.padded_size_bytes(), pixie::kAlignedStorageLineBytes); + EXPECT_EQ(storage.padded_view().size_bytes(), + pixie::kAlignedStorageLineBytes); EXPECT_EQ(reinterpret_cast(storage.as_bytes().data()) % 64, 0u); + storage.writable_bytes()[0] = std::byte{42}; + EXPECT_EQ(storage.as_bytes()[0], std::byte{42}); + storage.resize(64); + EXPECT_EQ(storage.size_bytes(), sizeof(std::uint64_t)); storage.writable_words64()[0] = 42; EXPECT_EQ(storage.as_words64()[0], 42u); storage.resize(0); EXPECT_TRUE(storage.empty()); + EXPECT_TRUE(storage.padded_view().empty()); EXPECT_GE(storage.allocated_bytes(), storage.size_bytes()); storage.shrink_to_fit(); } +TEST(AlignedStorageTest, CopiesCompleteWordsIntoAlignedStorage) { + std::array words = {1, 2, 3}; + const pixie::AlignedStorage storage{std::span(words)}; + words[0] = 4; + + EXPECT_EQ(storage.size_bytes(), 3 * sizeof(std::uint64_t)); + EXPECT_TRUE(std::ranges::equal(storage.as_words64(), + std::array{1, 2, 3})); + EXPECT_EQ(reinterpret_cast(storage.as_bytes().data()) % 64, + 0u); +} + TEST(ReadOnlyStorageViewTest, MutatingOperationsAreNotAvailable) { static_assert(!HasWritableBytes); static_assert(!HasResize); diff --git a/src/tests/wavelet_tree_tests.cpp b/src/tests/wavelet_tree_tests.cpp index d9b18e7..061cd68 100644 --- a/src/tests/wavelet_tree_tests.cpp +++ b/src/tests/wavelet_tree_tests.cpp @@ -10,11 +10,12 @@ #include #include #include +#include #include #include #include -using pixie::WaveletTree; +using WaveletTree = pixie::WaveletTree; namespace { @@ -74,8 +75,7 @@ WaveletArtifactOffsets locate_wavelet_artifact( skip_storage(); nodes.back().rank_num_bits = reader.position(); - reader.skip(7 * sizeof(std::uint64_t) + 2 * sizeof(std::uint32_t) + - 8 * sizeof(std::uint64_t) + 32 * sizeof(std::uint16_t)); + reader.skip(7 * sizeof(std::uint64_t) + 2 * sizeof(std::uint32_t)); for (std::size_t storage = 0; storage < 3; ++storage) { skip_storage(); } @@ -147,6 +147,106 @@ TEST(WaveletTreeTest, BasicSegment) { } } +TEST(WaveletTreeTest, SingleSymbolSupportsQueriesAndSerialization) { + const std::vector data = {0, 0, 0, 0}; + for (const auto build_type : {pixie::WaveletTreeBuildType::Standard, + pixie::WaveletTreeBuildType::Huffman}) { + const pixie::WaveletTree tree(1, data, build_type); + EXPECT_EQ(tree.rank(0, 3), 3u); + EXPECT_EQ(tree.select(0, 4), 3u); + EXPECT_EQ(tree.select(0, 5), tree.size()); + EXPECT_EQ(tree.get_segment(1, 4), (std::vector{0, 0, 0})); + + pixie::VectorOutputSink output; + pixie::BinaryWriter writer(output); + tree.serialize(writer); + writer.finish(); + const std::vector artifact = output.take(); + pixie::BinaryReader reader(artifact); + const auto restored = + pixie::WaveletTreeView::deserialize(reader); + EXPECT_TRUE(reader.empty()); + EXPECT_EQ(restored.get_segment(0, data.size()), data); + } +} + +TEST(WaveletTreeTest, OwningCopiesRetainIndependentRankSources) { + const std::vector data = {0, 1, 0, 1}; + const std::vector initial_data = {1, 1, 0, 0}; + std::unique_ptr> constructed_copy; + pixie::WaveletTree assigned_copy(2, initial_data); + { + const pixie::WaveletTree original(2, data); + constructed_copy = + std::make_unique>(original); + assigned_copy = original; + } + + for (const auto* copy : {constructed_copy.get(), &assigned_copy}) { + EXPECT_EQ(copy->rank(1, 3), 1u); + EXPECT_EQ(copy->select(1, 2), 3u); + EXPECT_EQ(copy->get_segment(0, data.size()), data); + } +} + +TEST(WaveletTreeTest, TypedByteSymbolsRoundTripWithoutWidening) { + std::vector data(256); + for (std::size_t symbol = 0; symbol < data.size(); ++symbol) { + data[symbol] = static_cast(symbol); + } + const pixie::WaveletTree tree( + 256, data, pixie::WaveletTreeBuildType::Huffman); + EXPECT_EQ(tree.get_segment(0, data.size()), data); + EXPECT_EQ(tree.select(255, 1), 255u); + + pixie::VectorOutputSink output; + pixie::BinaryWriter writer(output); + tree.serialize(writer); + writer.finish(); + const std::vector artifact = output.take(); + + pixie::BinaryReader reader(artifact); + const auto restored = + pixie::WaveletTreeView::deserialize(reader); + EXPECT_TRUE(reader.empty()); + EXPECT_EQ(restored.get_segment(0, data.size()), data); + + pixie::BinaryReader wrong_symbol_reader(artifact); + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( + wrong_symbol_reader), + std::invalid_argument); + EXPECT_EQ(wrong_symbol_reader.position(), 0u); +} + +TEST(WaveletTreeTest, BuildsFromCountsAndOneStreamedPass) { + const std::vector data = {3, 0, 1, 3, 2, 1, 0}; + const std::array counts = {2, 2, 1, 2}; + std::size_t passes = 0; + const pixie::WaveletTree tree( + 4, counts, + [&](auto&& emit) { + ++passes; + for (const std::uint8_t symbol : data) { + emit(symbol); + } + }, + pixie::WaveletTreeBuildType::Huffman); + EXPECT_EQ(passes, 1u); + EXPECT_EQ(tree.get_segment(0, data.size()), data); + + const std::array wrong_counts = {2, 2, 2, 1}; + EXPECT_THROW((pixie::WaveletTree( + 4, wrong_counts, + [&](auto&& emit) { + for (const std::uint8_t symbol : data) { + emit(symbol); + } + })), + std::invalid_argument); + EXPECT_THROW((pixie::WaveletTree(257, data)), + std::invalid_argument); +} + TEST(WaveletTreeTest, SmokeSelect) { std::vector> rank; for (size_t data_size = 8; data_size < (1 << 22); data_size <<= 1) { @@ -247,7 +347,8 @@ TEST(WaveletTreeTest, SerializationSmoke) { for (const auto validation : {pixie::DeserializationValidation::kQuick, pixie::DeserializationValidation::kFull}) { pixie::BinaryReader reader(serialized_data); - auto view_tree = pixie::WaveletTreeView::deserialize(reader, validation); + auto view_tree = pixie::WaveletTreeView::deserialize( + reader, validation); EXPECT_TRUE(reader.empty()); for (size_t i = 0; i <= data_size; i += 16) { @@ -284,9 +385,10 @@ TEST(WaveletTreeTest, SerializationAdvancesAcrossFramedArtifacts) { const std::vector artifacts = output.take(); pixie::BinaryReader reader(artifacts); - const auto first = pixie::WaveletTreeView::deserialize(reader); + const auto first = pixie::WaveletTreeView::deserialize(reader); EXPECT_FALSE(reader.empty()); - const auto second = pixie::WaveletTreeView::deserialize(reader); + const auto second = + pixie::WaveletTreeView::deserialize(reader); EXPECT_TRUE(reader.empty()); EXPECT_EQ(first.get_segment(0, data.size()), data); EXPECT_EQ(second.get_segment(0, data.size()), data); @@ -301,7 +403,8 @@ TEST(WaveletTreeTest, SerializationRoundTripsAnEmptyTree) { writer.finish(); const std::vector artifact = output.take(); pixie::BinaryReader reader(artifact); - const auto restored = pixie::WaveletTreeView::deserialize(reader); + const auto restored = + pixie::WaveletTreeView::deserialize(reader); EXPECT_TRUE(reader.empty()); EXPECT_TRUE(restored.empty()); @@ -329,7 +432,7 @@ TEST(WaveletTreeTest, SerializesDirectlyToMappedFile) { pixie::DeserializationValidation::kFull}) { pixie::BinaryReader reader(file.as_bytes()); const auto restored = - pixie::WaveletTreeView::deserialize(reader, validation); + pixie::WaveletTreeView::deserialize(reader, validation); EXPECT_TRUE(reader.empty()); EXPECT_EQ(restored.get_segment(0, data.size()), data); } @@ -350,11 +453,11 @@ TEST(WaveletTreeTest, std::vector bad_leaf = valid; overwrite_u64(bad_leaf, layout.leaves, 3); pixie::BinaryReader leaf_quick_reader(bad_leaf); - EXPECT_NO_THROW((void)pixie::WaveletTreeView::deserialize( + EXPECT_NO_THROW((void)pixie::WaveletTreeView::deserialize( leaf_quick_reader, pixie::DeserializationValidation::kQuick)); EXPECT_TRUE(leaf_quick_reader.empty()); pixie::BinaryReader leaf_full_reader(bad_leaf); - EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( leaf_full_reader, pixie::DeserializationValidation::kFull), std::invalid_argument); EXPECT_EQ(leaf_full_reader.position(), 0u); @@ -362,7 +465,7 @@ TEST(WaveletTreeTest, std::vector bad_child_length = valid; overwrite_u64(bad_child_length, layout.nodes[1].rank_num_bits, 3); pixie::BinaryReader child_full_reader(bad_child_length); - EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( child_full_reader, pixie::DeserializationValidation::kFull), std::invalid_argument); EXPECT_EQ(child_full_reader.position(), 0u); @@ -381,8 +484,9 @@ TEST(WaveletTreeTest, SerializationRejectsEveryTruncatedPrefixTransactionally) { SCOPED_TRACE(::testing::Message() << "size=" << size); pixie::BinaryReader reader( std::span(artifact).first(size)); - EXPECT_THROW((void)pixie::WaveletTreeView::deserialize(reader), - std::invalid_argument); + EXPECT_THROW( + (void)pixie::WaveletTreeView::deserialize(reader), + std::invalid_argument); EXPECT_EQ(reader.position(), 0u); } } @@ -406,7 +510,7 @@ TEST(WaveletTreeTest, SerializationRejectsUnalignedZeroCopyArtifacts) { std::ranges::copy(artifact, unaligned_artifact.begin() + 1); pixie::BinaryReader reader( std::span(unaligned_artifact).subspan(1)); - EXPECT_THROW((void)pixie::WaveletTreeView::deserialize(reader), + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize(reader), std::invalid_argument); EXPECT_EQ(reader.position(), 0u); } @@ -428,9 +532,9 @@ TEST(WaveletTreeTest, SerializationRejectsMalformedTopologyTransactionally) { for (const auto validation : {pixie::DeserializationValidation::kQuick, pixie::DeserializationValidation::kFull}) { pixie::BinaryReader reader(artifact); - EXPECT_THROW( - (void)pixie::WaveletTreeView::deserialize(reader, validation), - std::invalid_argument); + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( + reader, validation), + std::invalid_argument); EXPECT_EQ(reader.position(), 0u); } }; @@ -501,9 +605,9 @@ TEST(WaveletTreeTest, for (const auto validation : {pixie::DeserializationValidation::kQuick, pixie::DeserializationValidation::kFull}) { pixie::BinaryReader reader(artifact); - EXPECT_THROW( - (void)pixie::WaveletTreeView::deserialize(reader, validation), - std::exception); + EXPECT_THROW((void)pixie::WaveletTreeView::deserialize( + reader, validation), + std::exception); EXPECT_EQ(reader.position(), 0u); } };