From 2f65b7db24310c4249698e72efef2bf4251759d4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 4 Oct 2026 20:49:46 +0000 Subject: [PATCH] feat: load ced and voice bundle components from memory, bump ced.cpp and voice-detect.cpp ced.cpp and voice-detect.cpp can now load a model from memory, with a prefix for a model stored inside a larger GGUF. Move both submodules to the commits that add this (ced.cpp 736a4ee, voice-detect.cpp bca46bc). Use it for the ced and voice components of a bundle. The bundle is mapped read-only (mmap, MapViewOfFile on Windows) and handed to the prefixed loader, which copies only the tensors of that component during the call. No standalone copy, temporary file, memory file or environment variable is needed any more, so bundle_extract and PARAKEET_BUNDLE_NO_MEMFD are removed. Pages of the other components are never touched. Loading ced-small Q8_0 from the 1.1 GB standard bundle peaks 49 MB above an idle process and reads 24 MB from storage. Both loaders now check that the component exists and has the right kind, and add the loader's own error text to the message. The speaker encoder family is read from the prefixed keys of the bundle. The full-bundle test runs with a read-only TMPDIR, checks that the descriptor count and the working directory do not change, and measures the bytes read from storage per component with the page cache dropped. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] --- AGENTS.md | 4 +- CMakeLists.txt | 2 +- docs/bundle.md | 95 ++++++++----- src/bundle_extract.cpp | 252 ----------------------------------- src/bundle_extract.hpp | 53 -------- src/bundle_map.cpp | 80 +++++++++++ src/bundle_map.hpp | 45 +++++++ src/ced_tagger.cpp | 25 +++- src/ced_tagger.hpp | 6 +- src/speaker_encoder.cpp | 76 ++++++----- src/speaker_encoder.hpp | 16 ++- tests/test_bundle.cpp | 68 +++------- tests/test_bundle_full.cpp | 90 +++++++++---- third_party/ced.cpp | 2 +- third_party/voice-detect.cpp | 2 +- 15 files changed, 350 insertions(+), 466 deletions(-) delete mode 100644 src/bundle_extract.cpp delete mode 100644 src/bundle_extract.hpp create mode 100644 src/bundle_map.cpp create mode 100644 src/bundle_map.hpp diff --git a/AGENTS.md b/AGENTS.md index 413423b..f131286 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ src/ libparakeet implementation audio_io.hpp/cpp , dr_wav load + linear resample to 16k model_loader.hpp/cpp, GGUF -> ParakeetConfig + name->tensor bundle.hpp/cpp , bundle GGUF header: components, licences, default component (docs/bundle.md) - bundle_extract.hpp/cpp, one bundle component as a standalone GGUF (memfd or temporary file) for the path-only ced.cpp and voice-detect.cpp loaders + bundle_map.hpp/cpp, read-only map of a bundle handed to the load-from-memory hooks of ced.cpp and voice-detect.cpp (no temporary file) mel.cpp , log-mel frontend encoder.cpp / conformer.cpp / relpos_attention.cpp ctc_decoder.cpp , CTC head + greedy decode @@ -134,7 +134,7 @@ tests/ ctest targets test_model_loader.cpp , config + tensor map (model-dependent) test_bundle.cpp , bundle header, selection, partial read, Silero component, C-API (model-independent, synthetic files) test_bundle_models.cpp , real bundle == single-model files: transcript, VAD head, Silero (PARAKEET_TEST_BUNDLE, _ASR, _SILERO) - test_bundle_full.cpp , full bundle (asr, diar, ced, voice, vad) == single-model files, memfd and temporary-file paths (PARAKEET_TEST_BUNDLE_FULL, PARAKEET_TEST_FULL_*) + test_bundle_full.cpp , full bundle (asr, diar, ced, voice, vad) == single-model files, no temporary file or descriptor, storage bytes read per component (PARAKEET_TEST_BUNDLE_FULL, PARAKEET_TEST_FULL_*) python/check_bundle.py , bundle_gguf.py build/verify/refusals, licence policy, NOTICE (model-independent) test_capi.cpp , C-API load -> transcribe -> free (model-dependent) test_transcribe_speech.cpp, end-to-end CTC transcript (model-dependent) diff --git a/CMakeLists.txt b/CMakeLists.txt index 27dc107..6b900f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,7 +112,7 @@ set(PARAKEET_SRC src/vad_segmenter.cpp src/vad_json.cpp src/bundle.cpp - src/bundle_extract.cpp + src/bundle_map.cpp src/parakeet_capi.cpp src/common.cpp src/audio_io.cpp diff --git a/docs/bundle.md b/docs/bundle.md index a2dba41..0a1bb76 100644 --- a/docs/bundle.md +++ b/docs/bundle.md @@ -119,34 +119,69 @@ worth knowing: already has the head), and `--vad-component` that names a slice is an error. | `diar` | A Nemotron diarization GGUF (`parakeet.arch` `diarization`) | `DiarizationModel::load(path, name)`, through the same prefixed loader as `asr` | -| `ced` | A ced.cpp GGUF (`general.architecture` `ced`, CED-tiny, -mini, -small or -base) | `CedTagger::load(path, name)`, through a standalone copy (see "Components for third-party loaders") | -| `voice` | A voice-detect.cpp speaker encoder GGUF (`general.architecture` `voicedetect` with an embedding: WeSpeaker ResNet34, ECAPA, ERes2Net, CAM++) | `SpeakerEncoder::load(path, name)`, through a standalone copy | +| `ced` | A ced.cpp GGUF (`general.architecture` `ced`, CED-tiny, -mini, -small or -base) | `CedTagger::load(path, name)`, from a read-only map of the bundle (see "ced and voice components") | +| `voice` | A voice-detect.cpp speaker encoder GGUF (`general.architecture` `voicedetect` with an embedding: WeSpeaker ResNet34, ECAPA, ERes2Net, CAM++) | `SpeakerEncoder::load(path, name)`, from a read-only map of the bundle | A reader skips components of a kind it does not know: they are listed, but it does not load them and does not fail because they exist. The wav2vec2 analysis heads of voice-detect.cpp (age, gender, emotion) are not a `voice` component: their licence is non-commercial, and the build script refuses them. -## Components for third-party loaders - -`ced.cpp` and `voice-detect.cpp` open a model by file path only, and parakeet.cpp -does not change their sources. To load a `ced` or `voice` component, parakeet.cpp -writes it as a standalone single-model GGUF (keys and tensors without the prefix, -data streamed from the bundle, only that component's bytes read) and gives the -loader the path of that copy: - -* **Linux:** an anonymous in-memory file (`memfd_create`), opened as - `/proc/self/fd/N`. Nothing is written to disk. The loader reads it, then the - file is closed. The copy briefly uses memory equal to the component size. -* **Other systems, or if `memfd_create` or `/proc` is not usable, or when the - environment variable `PARAKEET_BUNDLE_NO_MEMFD` is set to a value other than - `0`:** a temporary file in the system temporary directory (`TMPDIR`, `TEMP`, - else `/tmp`), created owner-only with an exclusive create, and removed as soon as - the load has finished. A crash during the load can leave the file behind. The - path is only used on macOS and Windows by this fallback; it is not tested there. +## ced and voice components + +`ced.cpp` and `voice-detect.cpp` can load a model from memory: +`ced_capi_load_from_memory_prefixed(data, size, prefix)` and +`voicedetect_capi_load_from_memory_prefixed(data, size, prefix)`. They take the +bytes of a whole GGUF that holds the model under a prefix (`ced.`, `voice.`, the +component name and a dot), parse its header, and copy only the tensors of that +prefix into memory of their own. parakeet.cpp uses them as follows: + +1. It reads the bundle header and checks that the component exists and has the + right kind. +2. It maps the bundle file read-only (`mmap` on Linux and macOS, + `MapViewOfFile` on Windows; `src/bundle_map.cpp`). A map reads nothing by + itself: the operating system loads only the pages that are touched. +3. It calls the loader with the map and the prefix. The loader touches the header + and the tensors of the component, copies them, and returns. The map is + released at once. The loader keeps no pointer into it. + +There is no standalone copy of the component, no temporary file, no memory file +(`memfd`), and no environment variable. The bundle file is opened read-only for +the moment it takes to map it, and closed again. Linux, macOS and Windows run the +same code; only Linux is run in the tests and in CI here. + +**Memory and I/O.** The other components are never read. Measured with the +published standard bundle (1100.8 MB: asr 940.5 MB, diar 108.6 MB, ced-small Q8_0 +23.6 MB, voice WeSpeaker ResNet34 F32 26.5 MB, vad), loading one component in a +fresh process: + +| Component | Component size | Peak resident memory above an idle process | Memory kept after the load | Storage read (page cache dropped before) | +| --- | --- | --- | --- | --- | +| `ced` | 23.6 MB | 49 MB | 25 MB | 24.2 MB | +| `voice` | 26.5 MB | 54 MB | 27 MB | 26.5 MB | + +The peak is the copy of the component (anonymous memory, kept) plus the pages of +the map that were touched (file cache, reclaimable, released with the map). It +depends on the size of the component and not on the size of the bundle: the 338 MB +small bundle gives the same figures. The previous design wrote the component to an +in-memory file first, so it held one more copy of the component while the loader ran (not measured). + +**Limits.** + +* A model file must not change while it loads. If another process shortens the + bundle while it is mapped, a read past the new end ends the process + (`SIGBUS`, or an access violation on Windows). Model files are meant to be + immutable in service. +* The whole bundle needs address space, not memory. A 32-bit process cannot map a + bundle larger than its address space; the load then fails with "cannot map". +* A tensor name in the bundle is `.`, and ggml keeps names in 63 + bytes, so the stripped names the loaders see are shorter than that. The component loaded this way gives the same output as the single-model file: the CED class scores and the speaker embeddings are bitwise equal in the tests. +Load errors come from `ced_capi_last_error(NULL)` and +`voicedetect_capi_last_load_error()` and are added to the message of +`CedTagger::load` and `SpeakerEncoder::load`. The speaker identity of a `voice` component (`parakeet_capi_speaker_identity`) is `sha256:` plus the `source_sha256` of its header, which is the sha256 of the @@ -154,12 +189,6 @@ single-model file. A voice enrolled with the standalone file therefore matches t same model inside a bundle. The header is trusted for this: load bundles you trust. -Cleaner alternative, a follow-up outside this repository: a `load_from_buffer` -(or `load_from_reader`) entry point in ced.cpp and voice-detect.cpp, so a -component could be handed over as a memory range with no copy and no temporary -file. Both projects are owned by the same maintainer. When it exists, -`bundle_extract.cpp` can be replaced by a call that passes the mapped range. - ## Compatibility rules 1. **Plain files are unchanged.** A single-model GGUF has no bundle keys. Every @@ -217,16 +246,16 @@ A component is chosen by name or by default. ## Partial loading A loader reads the header and the tensor table, then reads only the tensors of -the component it was asked for. The ASR and -diarization loaders read them into one private memory block, and the Silero loader -seeks to each of its tensors. The `ced` and `voice` loaders read them while they -write the standalone copy, then read that copy. The bytes of the other components -are never read. A test checks the number of bytes read from -the file for each component. +the component it was asked for. The ASR and diarization loaders read them into +one private memory block, and the Silero loader seeks to each of its tensors. The +`ced` and `voice` loaders get a read-only map of the bundle and copy the tensors +of their component out of it (see "ced and voice components"). The bytes of the +other components are never read. A test checks the bytes read from storage for +each component. Memory: a loaded component uses the same memory as the same single-model file. -The memory of components that are not loaded is not used. The file stays on disk -and is not memory mapped. +The memory of components that are not loaded is not used. For `ced` and `voice` +the map exists only during the load. ## Building, inspecting and verifying diff --git a/src/bundle_extract.cpp b/src/bundle_extract.cpp deleted file mode 100644 index d360449..0000000 --- a/src/bundle_extract.cpp +++ /dev/null @@ -1,252 +0,0 @@ -#include "bundle_extract.hpp" - -#include -#include -#include -#include -#include -#include - -#include "ggml.h" -#include "gguf.h" - -#if defined(__linux__) -#include -#include -#include -#include -#elif !defined(_WIN32) -#include -#include -#include -#endif - -namespace pk { - -namespace { - -#if defined(_WIN32) -typedef long long off_t_compat; -#define PK_FSEEK _fseeki64 -#define PK_FTELL _ftelli64 -#else -typedef off_t off_t_compat; -#define PK_FSEEK fseeko -#define PK_FTELL ftello -#endif - -void set_err(std::string* err, const std::string& m) { - if (err) *err = m; -} - -// Copies one key of `g` to `out` under `name`. Unknown value types are skipped. -void copy_kv(gguf_context* out, gguf_context* g, int64_t i, const std::string& name) { - const char* k = name.c_str(); - switch (gguf_get_kv_type(g, i)) { - case GGUF_TYPE_UINT8: gguf_set_val_u8(out, k, gguf_get_val_u8(g, i)); break; - case GGUF_TYPE_INT8: gguf_set_val_i8(out, k, gguf_get_val_i8(g, i)); break; - case GGUF_TYPE_UINT16: gguf_set_val_u16(out, k, gguf_get_val_u16(g, i)); break; - case GGUF_TYPE_INT16: gguf_set_val_i16(out, k, gguf_get_val_i16(g, i)); break; - case GGUF_TYPE_UINT32: gguf_set_val_u32(out, k, gguf_get_val_u32(g, i)); break; - case GGUF_TYPE_INT32: gguf_set_val_i32(out, k, gguf_get_val_i32(g, i)); break; - case GGUF_TYPE_FLOAT32: gguf_set_val_f32(out, k, gguf_get_val_f32(g, i)); break; - case GGUF_TYPE_UINT64: gguf_set_val_u64(out, k, gguf_get_val_u64(g, i)); break; - case GGUF_TYPE_INT64: gguf_set_val_i64(out, k, gguf_get_val_i64(g, i)); break; - case GGUF_TYPE_FLOAT64: gguf_set_val_f64(out, k, gguf_get_val_f64(g, i)); break; - case GGUF_TYPE_BOOL: gguf_set_val_bool(out, k, gguf_get_val_bool(g, i)); break; - case GGUF_TYPE_STRING: gguf_set_val_str(out, k, gguf_get_val_str(g, i)); break; - case GGUF_TYPE_ARRAY: { - const gguf_type at = gguf_get_arr_type(g, i); - const size_t n = gguf_get_arr_n(g, i); - if (at == GGUF_TYPE_STRING) { - std::vector v(n); - for (size_t j = 0; j < n; ++j) v[j] = gguf_get_arr_str(g, i, j); - gguf_set_arr_str(out, k, v.data(), n); - } else if (at != GGUF_TYPE_ARRAY) { - gguf_set_arr_data(out, k, at, gguf_get_arr_data(g, i), n); - } - break; - } - default: break; - } -} - -bool no_memfd_requested() { - const char* e = std::getenv("PARAKEET_BUNDLE_NO_MEMFD"); - return e && *e && std::strcmp(e, "0") != 0; -} - -// Writes the standalone GGUF of `comp` to `f` (a freshly opened, empty file). -// Returns the number of bytes written, or 0 with *err set. -uint64_t write_component(const std::string& bundle, const std::string& comp, FILE* f, std::string* err) { - ggml_context* meta = nullptr; - gguf_init_params ip{/*no_alloc*/ true, &meta}; - gguf_context* g = gguf_init_from_file(bundle.c_str(), ip); - if (!g) { - set_err(err, "cannot read " + bundle + " as a GGUF file"); - return 0; - } - const std::string pre = comp + "."; - gguf_context* out = gguf_init_empty(); - ggml_context* tctx = nullptr; - FILE* in = nullptr; - uint64_t written = 0; - bool ok = false; - do { - for (int64_t i = 0; i < gguf_get_n_kv(g); ++i) { - const char* k = gguf_get_key(g, i); - if (std::strncmp(k, pre.c_str(), pre.size()) != 0) continue; - copy_kv(out, g, i, std::string(k + pre.size())); - } - const int64_t nt = gguf_get_n_tensors(g); - std::vector ids; - for (int64_t i = 0; i < nt; ++i) - if (std::strncmp(gguf_get_tensor_name(g, i), pre.c_str(), pre.size()) == 0) ids.push_back(i); - if (ids.empty()) { - set_err(err, "bundle " + bundle + ": component '" + comp + "' has no tensors"); - break; - } - // Tensor descriptors only (no data): the writer needs type, shape and name. - ggml_init_params tp{ggml_tensor_overhead() * ids.size() + (1u << 16), nullptr, /*no_alloc*/ true}; - tctx = ggml_init(tp); - if (!tctx) { set_err(err, "out of memory"); break; } - bool bad = false; - for (int64_t i : ids) { - const char* nm = gguf_get_tensor_name(g, i); - ggml_tensor* s = ggml_get_tensor(meta, nm); - ggml_tensor* t = s ? ggml_new_tensor(tctx, s->type, GGML_MAX_DIMS, s->ne) : nullptr; - if (!t) { set_err(err, std::string("cannot describe tensor ") + nm); bad = true; break; } - ggml_set_name(t, nm + pre.size()); - gguf_add_tensor(out, t); - } - if (bad) break; - if (!gguf_write_to_file_ptr(out, f, /*only_meta*/ true)) { set_err(err, "cannot write the component file"); break; } - written = (uint64_t)gguf_get_data_offset(out); // header + tensor table, padded - in = std::fopen(bundle.c_str(), "rb"); - if (!in) { set_err(err, "cannot open " + bundle); break; } - PK_FSEEK(in, 0, SEEK_END); - const uint64_t file_size = (uint64_t)PK_FTELL(in); - const uint64_t base = (uint64_t)gguf_get_data_offset(g); - std::vector buf(1u << 20); - bool failed = false; - for (size_t n = 0; n < ids.size() && !failed; ++n) { - const int64_t i = ids[n]; - const uint64_t size = gguf_get_tensor_size(g, i); - const uint64_t off = base + gguf_get_tensor_offset(g, i); - if (off + size > file_size) { - set_err(err, std::string("bundle ") + bundle + " is truncated (tensor " + gguf_get_tensor_name(g, i) + ")"); - failed = true; - break; - } - // Pad up to this tensor's aligned offset in the output. - const uint64_t want = (uint64_t)gguf_get_data_offset(out) + gguf_get_tensor_offset(out, (int64_t)n); - while (written < want) { - const char zero[64] = {0}; - const size_t k = (size_t)std::min(sizeof zero, want - written); - if (std::fwrite(zero, 1, k, f) != k) { failed = true; break; } - written += k; - } - if (failed || PK_FSEEK(in, (off_t_compat)off, SEEK_SET) != 0) { failed = true; break; } - for (uint64_t done = 0; done < size && !failed;) { - const size_t k = (size_t)std::min(buf.size(), size - done); - if (std::fread(buf.data(), 1, k, in) != k || std::fwrite(buf.data(), 1, k, f) != k) failed = true; - done += k; - written += k; - } - } - if (failed) { - if (!err || err->empty()) set_err(err, "cannot copy the component data (disk or memory full?)"); - break; - } - // Pad the end to the alignment like the single-pass writer does. - const size_t al = gguf_get_alignment(out); - while (written % al) { - if (std::fputc(0, f) == EOF) { failed = true; break; } - ++written; - } - if (failed || std::fflush(f) != 0) { set_err(err, "cannot write the component file"); break; } - ok = true; - } while (false); - if (in) std::fclose(in); - if (tctx) ggml_free(tctx); - gguf_free(out); - gguf_free(g); - if (meta) ggml_free(meta); - return ok ? written : 0; -} - -} // namespace - -ComponentFile::~ComponentFile() { -#if !defined(_WIN32) - if (fd_ >= 0) ::close(fd_); -#endif - if (remove_) { - std::error_code ec; - std::filesystem::remove(path_, ec); - } -} - -std::unique_ptr ComponentFile::create(const std::string& bundle, const std::string& comp, - std::string* err) { - std::unique_ptr cf(new ComponentFile()); -#if defined(__linux__) - if (!no_memfd_requested()) { - const int fd = (int)memfd_create("parakeet-component", MFD_CLOEXEC); - if (fd >= 0) { - const std::string p = "/proc/self/fd/" + std::to_string(fd); - FILE* f = ::fdopen(::dup(fd), "wb"); - std::string e; - const uint64_t n = f ? write_component(bundle, comp, f, &e) : 0; - if (f) std::fclose(f); - struct stat st {}; - if (n > 0 && ::stat(p.c_str(), &st) == 0) { // /proc must be mounted for the path to work - cf->fd_ = fd; - cf->path_ = p; - cf->in_memory_ = true; - cf->size_ = n; - return cf; - } - ::close(fd); - // A real failure (missing component, truncated bundle) is final; only fall back - // when the in-memory file itself could not be used. - if (n == 0 && !e.empty() && e.find("cannot write") == std::string::npos && - e.find("cannot copy") == std::string::npos) { - set_err(err, e); - return nullptr; - } - } - } -#endif - // Temporary file. - std::error_code ec; - const std::filesystem::path dir = std::filesystem::temp_directory_path(ec); - if (ec) { set_err(err, "no temporary directory for the component file"); return nullptr; } - FILE* f = nullptr; - std::string path; -#if defined(_WIN32) - for (int attempt = 0; attempt < 100 && !f; ++attempt) { - path = (dir / ("parakeet-component-" + std::to_string(std::rand()) + "-" + std::to_string(attempt) + ".gguf")).string(); - if (std::filesystem::exists(path)) continue; - f = std::fopen(path.c_str(), "wb"); - } -#else - for (int attempt = 0; attempt < 100 && !f; ++attempt) { - path = (dir / ("parakeet-component-" + std::to_string((long)::getpid()) + "-" + std::to_string(std::rand()) + - "-" + std::to_string(attempt) + ".gguf")).string(); - const int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600); // never follow or reuse a file - if (fd >= 0) f = ::fdopen(fd, "wb"); - } -#endif - if (!f) { set_err(err, "cannot create a temporary file in " + dir.string()); return nullptr; } - cf->path_ = path; - cf->remove_ = true; // from here the destructor deletes the file, also on failure - std::string e; - const uint64_t n = write_component(bundle, comp, f, &e); - std::fclose(f); - if (n == 0) { set_err(err, e.empty() ? "cannot write the temporary file " + path : e); return nullptr; } - cf->size_ = n; - return cf; -} - -} // namespace pk diff --git a/src/bundle_extract.hpp b/src/bundle_extract.hpp deleted file mode 100644 index 829885b..0000000 --- a/src/bundle_extract.hpp +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once -// A standalone single-model GGUF made from one component of a bundle, for a -// third-party loader that only opens a file by path (ced.cpp, voice-detect.cpp). -// -// The component's keys and tensors are written without the "." prefix, -// so the result is a valid single-model GGUF that the loader reads as if it had -// been published on its own. Tensor data is streamed from the bundle in blocks; -// only the requested component's bytes are read. -// -// Where the file lives: -// * Linux: an anonymous in-memory file (memfd_create), opened through -// /proc/self/fd/N. Nothing is written to disk. -// * Elsewhere, or when memfd_create or /proc is not available, or when the -// environment variable PARAKEET_BUNDLE_NO_MEMFD is set to a value other than -// "0": a temporary file in the system temporary directory (TMPDIR, TEMP, -// else /tmp), created owner-only and removed when the ComponentFile is -// destroyed. A crash between creation and removal leaves the file behind. -// -// Keep the object alive until the loader has finished opening the file, then -// let it go out of scope. -#include -#include -#include - -namespace pk { - -class ComponentFile { -public: - // Returns nullptr and writes the reason to *err on failure (bundle unreadable, - // component missing, truncated bundle, no space for the file). - static std::unique_ptr create(const std::string& bundle, const std::string& component, - std::string* err); - ~ComponentFile(); - ComponentFile(const ComponentFile&) = delete; - ComponentFile& operator=(const ComponentFile&) = delete; - - // Path to pass to a path-only loader. Valid until the object is destroyed. - const std::string& path() const { return path_; } - // True when the file is an in-memory file, false for a temporary file on disk. - bool in_memory() const { return in_memory_; } - // Size of the standalone GGUF in bytes. - uint64_t size() const { return size_; } - -private: - ComponentFile() = default; - std::string path_; - bool in_memory_ = false; - int fd_ = -1; // memfd, when in_memory_ - bool remove_ = false; // temporary file to delete - uint64_t size_ = 0; -}; - -} // namespace pk diff --git a/src/bundle_map.cpp b/src/bundle_map.cpp new file mode 100644 index 0000000..954f54a --- /dev/null +++ b/src/bundle_map.cpp @@ -0,0 +1,80 @@ +#include "bundle_map.hpp" + +#include +#include + +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#include +#include +#include +#endif + +namespace pk { + +namespace { +void set_err(std::string* err, const std::string& m) { + if (err) *err = m; +} +} // namespace + +#if defined(_WIN32) + +MappedFile::~MappedFile() { + if (data_) UnmapViewOfFile(data_); + if (mapping_) CloseHandle(static_cast(mapping_)); +} + +std::unique_ptr MappedFile::open(const std::string& path, std::string* err) { + HANDLE f = CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) { set_err(err, "cannot open " + path); return nullptr; } + LARGE_INTEGER sz; + if (!GetFileSizeEx(f, &sz) || sz.QuadPart <= 0 || (uint64_t)sz.QuadPart > std::numeric_limits::max()) { + CloseHandle(f); + set_err(err, "cannot map " + path + " (empty or too large)"); + return nullptr; + } + std::unique_ptr m(new MappedFile()); + HANDLE h = CreateFileMappingA(f, nullptr, PAGE_READONLY, 0, 0, nullptr); + CloseHandle(f); // the mapping keeps the file open + if (!h) { set_err(err, "cannot map " + path); return nullptr; } + m->mapping_ = h; + m->data_ = MapViewOfFile(h, FILE_MAP_READ, 0, 0, 0); + if (!m->data_) { set_err(err, "cannot map " + path); return nullptr; } + m->size_ = (size_t)sz.QuadPart; + return m; +} + +#else + +MappedFile::~MappedFile() { + if (data_) ::munmap(const_cast(data_), size_); +} + +std::unique_ptr MappedFile::open(const std::string& path, std::string* err) { + const int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) { set_err(err, "cannot open " + path); return nullptr; } + struct stat st {}; + if (::fstat(fd, &st) != 0 || st.st_size <= 0 || (uint64_t)st.st_size > std::numeric_limits::max()) { + ::close(fd); + set_err(err, "cannot map " + path + " (empty or too large)"); + return nullptr; + } + void* p = ::mmap(nullptr, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); + ::close(fd); // the mapping stays valid + if (p == MAP_FAILED) { set_err(err, "cannot map " + path + " into memory"); return nullptr; } + std::unique_ptr m(new MappedFile()); + m->data_ = p; + m->size_ = (size_t)st.st_size; + return m; +} + +#endif + +} // namespace pk diff --git a/src/bundle_map.hpp b/src/bundle_map.hpp new file mode 100644 index 0000000..e5d3ef0 --- /dev/null +++ b/src/bundle_map.hpp @@ -0,0 +1,45 @@ +#pragma once +// A read-only memory map of a whole file, for the ced and voice components of a +// bundle (docs/bundle.md). ced.cpp and voice-detect.cpp load a bundle component +// from memory: they take the bytes of the whole bundle and a prefix, parse the +// header, and copy only the tensors of that component. Mapping the file means the +// operating system reads only the pages that are touched (the header and the +// component's tensors), so the other components are never read, and the map +// itself needs no private memory. +// +// The same code runs on Linux and macOS (mmap) and on Windows (MapViewOfFile). +// No temporary file is made. The file is opened read-only, mapped, and closed +// again before open() returns; the map stays valid until the object is destroyed. +// +// Keep the object alive only for the duration of the load. If another process +// shortens the file while it is mapped, a read past the new end can end the +// process (SIGBUS, or an access violation on Windows); model files must stay +// unchanged while they are loaded. +#include +#include +#include + +namespace pk { + +class MappedFile { +public: + // Returns nullptr and writes the reason to *err when the file cannot be + // opened, is empty, or is too large to map in this process. + static std::unique_ptr open(const std::string& path, std::string* err); + ~MappedFile(); + MappedFile(const MappedFile&) = delete; + MappedFile& operator=(const MappedFile&) = delete; + + const void* data() const { return data_; } + size_t size() const { return size_; } + +private: + MappedFile() = default; + const void* data_ = nullptr; + size_t size_ = 0; +#if defined(_WIN32) + void* mapping_ = nullptr; // HANDLE of the file mapping +#endif +}; + +} // namespace pk diff --git a/src/ced_tagger.cpp b/src/ced_tagger.cpp index b902e8f..00a3701 100644 --- a/src/ced_tagger.cpp +++ b/src/ced_tagger.cpp @@ -1,6 +1,7 @@ #include "ced_tagger.hpp" -#include "bundle_extract.hpp" +#include "bundle.hpp" +#include "bundle_map.hpp" #include "gguf.h" #ifdef PARAKEET_WITH_CED @@ -30,13 +31,27 @@ std::unique_ptr CedTagger::load(const std::string& path, const std::s if (component.empty()) { c = ced_capi_load(path.c_str()); } else { + // The bundle is mapped read-only and ced.cpp copies the tensors of the + // component out of the map during the call (docs/bundle.md). + BundleInfo info; std::string e; - std::unique_ptr cf = ComponentFile::create(path, component, &e); - if (!cf) { if (err) *err = e; return nullptr; } - c = ced_capi_load(cf->path().c_str()); // the file is removed when cf goes out of scope + if (!read_bundle_info(path, info, &e)) { if (err) *err = e; return nullptr; } + const BundleComponent* bc = info.find(component); + if (!bc) { if (err) *err = "the bundle has no component \"" + component + "\""; return nullptr; } + if (bc->kind != kBundleKindCed) { + if (err) *err = "component \"" + component + "\" is of kind \"" + bc->kind + "\", not \"ced\""; + return nullptr; + } + std::unique_ptr map = MappedFile::open(path, &e); + if (!map) { if (err) *err = e; return nullptr; } + c = ced_capi_load_from_memory_prefixed(map->data(), map->size(), (component + ".").c_str()); } if (!c) { - if (err && err->empty()) *err = "cannot load the sound model " + (component.empty() ? path : component); + if (err) { + const char* m = ced_capi_last_error(nullptr); + *err = "cannot load the sound model " + (component.empty() ? path : component) + + ((m && *m) ? std::string(": ") + m : std::string()); + } return nullptr; } std::unique_ptr t(new CedTagger()); diff --git a/src/ced_tagger.hpp b/src/ced_tagger.hpp index 9054232..89aaa78 100644 --- a/src/ced_tagger.hpp +++ b/src/ced_tagger.hpp @@ -19,9 +19,9 @@ class CedTagger { static bool available(); // nullptr on failure (or when unavailable). With a non-empty `component`, // `gguf_path` is a bundle GGUF (docs/bundle.md) and the model is its component - // of kind "ced". ced.cpp opens models by path only, so the component is first - // written as a standalone GGUF (an in-memory file on Linux, else a temporary - // file removed after the load; see bundle_extract.hpp). `err`, when given, + // of kind "ced". The bundle is mapped read-only and ced.cpp copies the + // component's tensors out of the map during the call: no temporary file, and + // the other components are not read (see bundle_map.hpp). `err`, when given, // receives the reason for a failure. static std::unique_ptr load(const std::string& gguf_path, const std::string& component = "", std::string* err = nullptr); diff --git a/src/speaker_encoder.cpp b/src/speaker_encoder.cpp index a7b0564..b6872b3 100644 --- a/src/speaker_encoder.cpp +++ b/src/speaker_encoder.cpp @@ -1,7 +1,7 @@ #include "speaker_encoder.hpp" #include "bundle.hpp" -#include "bundle_extract.hpp" +#include "bundle_map.hpp" #include "speaker_model_identity.hpp" #include "gguf.h" @@ -24,19 +24,19 @@ bool gguf_is_voicedetect(const std::string& path) { return vd; } -std::string speaker_encoder_family(const std::string& path, int dim_fallback) { +std::string speaker_encoder_family(const std::string& path, int dim_fallback, const std::string& prefix) { gguf_init_params p{/*no_alloc=*/true, /*ctx=*/nullptr}; gguf_context* g = gguf_init_from_file(path.c_str(), p); if (!g) return ""; - auto str = [&](const char* key) { - const int64_t id = gguf_find_key(g, key); + auto str = [&](const std::string& key) { + const int64_t id = gguf_find_key(g, (prefix + key).c_str()); return (id >= 0 && gguf_get_kv_type(g, id) == GGUF_TYPE_STRING) ? std::string(gguf_get_val_str(g, id)) : std::string(); }; std::string out; if (str("general.architecture") == "voicedetect") { long long dim = dim_fallback; - const int64_t id = gguf_find_key(g, "voicedetect.embedding_dim"); + const int64_t id = gguf_find_key(g, (prefix + "voicedetect.embedding_dim").c_str()); if (id >= 0) { const gguf_type t = gguf_get_kv_type(g, id); if (t == GGUF_TYPE_UINT32) dim = gguf_get_val_u32(g, id); @@ -61,48 +61,58 @@ bool SpeakerEncoder::available() { return true; } std::unique_ptr SpeakerEncoder::load(const std::string& path, const std::string& component, std::string* err) { std::string weights; - std::unique_ptr cf; - std::string load_path = path; try { if (component.empty()) { weights = speaker_model_identity(path); - } else { - BundleInfo info; - std::string e; - if (!read_bundle_info(path, info, &e)) { if (err) *err = e; return nullptr; } - const BundleComponent* c = info.find(component); - if (!c) { if (err) *err = "the bundle has no component \"" + component + "\""; return nullptr; } - const std::string& h = c->source_sha256; - bool hex = h.size() == 64; - for (char ch : h) hex = hex && ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')); - if (!hex) { - if (err) *err = "voice component \"" + component + "\" has no valid source_sha256 in the bundle " - "header, which is its speaker model identity"; + voicedetect_ctx* c = voicedetect_capi_load(path.c_str()); + std::unique_ptr enc = adopt(c, path, "", weights, err); + if (enc && speaker_model_identity(path) != weights) { + if (err) *err = "the speaker model changed during load"; return nullptr; } - weights = "sha256:" + h; - cf = ComponentFile::create(path, component, &e); - if (!cf) { if (err) *err = e; return nullptr; } - load_path = cf->path(); // the file is removed when cf goes out of scope + return enc; } - std::unique_ptr enc = load_unchecked(load_path, weights, err); - if (enc && component.empty() && speaker_model_identity(path) != weights) { - if (err) *err = "the speaker model changed during load"; + BundleInfo info; + std::string e; + if (!read_bundle_info(path, info, &e)) { if (err) *err = e; return nullptr; } + const BundleComponent* c = info.find(component); + if (!c) { if (err) *err = "the bundle has no component \"" + component + "\""; return nullptr; } + if (c->kind != kBundleKindVoice) { + if (err) *err = "component \"" + component + "\" is of kind \"" + c->kind + "\", not \"voice\""; return nullptr; } - return enc; + const std::string& h = c->source_sha256; + bool hex = h.size() == 64; + for (char ch : h) hex = hex && ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')); + if (!hex) { + if (err) *err = "voice component \"" + component + "\" has no valid source_sha256 in the bundle " + "header, which is its speaker model identity"; + return nullptr; + } + weights = "sha256:" + h; + // The bundle is mapped read-only and voice-detect.cpp copies the tensors of the + // component out of the map during the call (docs/bundle.md). + std::unique_ptr map = MappedFile::open(path, &e); + if (!map) { if (err) *err = e; return nullptr; } + const std::string prefix = component + "."; + voicedetect_ctx* vc = voicedetect_capi_load_from_memory_prefixed(map->data(), map->size(), prefix.c_str()); + return adopt(vc, path, prefix, weights, err); } catch (...) { if (err && err->empty()) *err = "cannot read the speaker model " + (component.empty() ? path : component); return nullptr; } } -std::unique_ptr SpeakerEncoder::load_unchecked(const std::string& path, - const std::string& weights, - std::string* err) { - voicedetect_ctx* c = voicedetect_capi_load(path.c_str()); +// Takes ownership of `c`, which came from loading `path` (for a bundle component, its +// keys carry `prefix`). +std::unique_ptr SpeakerEncoder::adopt(void* ctx, const std::string& path, const std::string& prefix, + const std::string& weights, std::string* err) { + voicedetect_ctx* c = static_cast(ctx); if (!c) { - if (err && err->empty()) *err = "cannot load the speaker model"; + if (err) { + const char* m = voicedetect_capi_last_load_error(); + *err = std::string("cannot load the speaker model") + ((m && *m) ? std::string(": ") + m : std::string()); + } return nullptr; } const int dim = voicedetect_capi_embedding_dim(c); @@ -114,7 +124,7 @@ std::unique_ptr SpeakerEncoder::load_unchecked(const std::string std::unique_ptr e(new SpeakerEncoder()); e->ctx_ = c; e->dim_ = dim; - e->fp_.family = speaker_encoder_family(path, dim); + e->fp_.family = speaker_encoder_family(path, dim, prefix); e->fp_.weights = weights; return e; } diff --git a/src/speaker_encoder.hpp b/src/speaker_encoder.hpp index 3a9b657..2c0fc73 100644 --- a/src/speaker_encoder.hpp +++ b/src/speaker_encoder.hpp @@ -18,9 +18,9 @@ class SpeakerEncoder { // nullptr on failure, when unavailable, or when the GGUF has no speaker // embedding (for example an age/gender/emotion model). With a non-empty // `component`, `gguf_path` is a bundle GGUF (docs/bundle.md) and the encoder is - // its component of kind "voice". voice-detect.cpp opens models by path only, so - // the component is first written as a standalone GGUF (an in-memory file on - // Linux, else a temporary file removed after the load; see bundle_extract.hpp). + // its component of kind "voice". The bundle is mapped read-only and + // voice-detect.cpp copies the component's tensors out of the map during the call: + // no temporary file, and the other components are not read (see bundle_map.hpp). // `err`, when given, receives the reason for a failure. static std::unique_ptr load(const std::string& gguf_path, const std::string& component = "", std::string* err = nullptr); @@ -42,8 +42,8 @@ class SpeakerEncoder { private: SpeakerEncoder() = default; - static std::unique_ptr load_unchecked(const std::string& path, - const std::string& weights, std::string* err); + static std::unique_ptr adopt(void* ctx, const std::string& path, const std::string& prefix, + const std::string& weights, std::string* err); void* ctx_ = nullptr; // voicedetect_ctx* int dim_ = 0; EncoderFingerprint fp_; @@ -54,8 +54,10 @@ class SpeakerEncoder { // "voicedetect:::" // A missing key leaves its field empty ("voicedetect::name:256"). "" when the file // is not a readable voice-detect GGUF. `dim_fallback` is used when the GGUF has no -// embedding_dim key (0 leaves the field empty). -std::string speaker_encoder_family(const std::string& gguf_path, int dim_fallback = 0); +// embedding_dim key (0 leaves the field empty). With a non-empty `prefix` (for a bundle +// component: ".") every key is looked up under that prefix. +std::string speaker_encoder_family(const std::string& gguf_path, int dim_fallback = 0, + const std::string& prefix = ""); // True when the GGUF's general.architecture is "voicedetect". Reads only the header. bool gguf_is_voicedetect(const std::string& gguf_path); diff --git a/tests/test_bundle.cpp b/tests/test_bundle.cpp index eafb6f2..386489f 100644 --- a/tests/test_bundle.cpp +++ b/tests/test_bundle.cpp @@ -17,7 +17,9 @@ #include #include "bundle.hpp" -#include "bundle_extract.hpp" +#include "bundle_map.hpp" +#include "ced_tagger.hpp" +#include "speaker_encoder.hpp" #include "ggml.h" #include "gguf.h" #include "model.hpp" @@ -384,54 +386,26 @@ int main() { CHECK(!pk::read_bundle_info(Bbad, info, &err)); } - // --- ComponentFile: a component as a standalone GGUF (for path-only third-party loaders) --- + // --- MappedFile: the read-only map handed to the ced and voice loaders --- { - char tmpl[] = "test_bundle_tmpdir_XXXXXX"; - const std::string tmpdir = ::mkdtemp(tmpl) ? tmpl : ""; - CHECK(!tmpdir.empty()); - for (int pass = 0; pass < 2; ++pass) { - if (pass == 1) { ::setenv("PARAKEET_BUNDLE_NO_MEMFD", "1", 1); ::setenv("TMPDIR", tmpdir.c_str(), 1); } - for (const char* comp : {"asr", "vad"}) { - const std::string& plain = std::string(comp) == "asr" ? plain_asr : plain_sil; - std::string cerr; - std::string path; - { - std::unique_ptr cf = pk::ComponentFile::create(B, comp, &cerr); - CHECK(cf != nullptr); - if (!cf) continue; - CHECK(cf->in_memory() == (pass == 0)); - path = cf->path(); - // Same keys, same types and the same tensor bytes as the single-model file. - ggml_context *c1 = nullptr, *c2 = nullptr; - gguf_context* g1 = gguf_init_from_file(plain.c_str(), {false, &c1}); - gguf_context* g2 = gguf_init_from_file(path.c_str(), {false, &c2}); - CHECK(g1 && g2); - if (g1 && g2) { - CHECK(gguf_get_n_kv(g1) == gguf_get_n_kv(g2) && gguf_get_n_tensors(g1) == gguf_get_n_tensors(g2)); - for (int64_t i = 0; i < gguf_get_n_kv(g1); ++i) { - const int64_t j = gguf_find_key(g2, gguf_get_key(g1, i)); - CHECK(j >= 0 && gguf_get_kv_type(g1, i) == gguf_get_kv_type(g2, j)); - } - for (ggml_tensor* t = ggml_get_first_tensor(c1); t; t = ggml_get_next_tensor(c1, t)) { - ggml_tensor* u = ggml_get_tensor(c2, t->name); - CHECK(u && u->type == t->type && ggml_nbytes(u) == ggml_nbytes(t) && - std::memcmp(u->data, t->data, ggml_nbytes(t)) == 0); - } - } - if (g1) gguf_free(g1); - if (g2) gguf_free(g2); - if (c1) ggml_free(c1); - if (c2) ggml_free(c2); - } - if (pass == 1) CHECK(access(path.c_str(), F_OK) != 0); // the temporary file is removed - } - std::string cerr; - CHECK(pk::ComponentFile::create(B, "nope", &cerr) == nullptr && !cerr.empty()); - CHECK(pk::ComponentFile::create("test_bundle.does-not-exist", "asr", &cerr) == nullptr); + std::ifstream in(B, std::ios::binary); + const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + std::string merr; + { + std::unique_ptr m = pk::MappedFile::open(B, &merr); + CHECK(m != nullptr); + if (m) CHECK(m->size() == bytes.size() && std::memcmp(m->data(), bytes.data(), bytes.size()) == 0); } - ::unsetenv("PARAKEET_BUNDLE_NO_MEMFD"); - ::rmdir(tmpdir.c_str()); // fails if a temporary file was left behind - CHECK(access(tmpdir.c_str(), F_OK) != 0); + merr.clear(); + CHECK(pk::MappedFile::open("test_bundle.does-not-exist", &merr) == nullptr && !merr.empty()); + const std::string empty = dir + "empty.bin"; + { std::ofstream out(empty, std::ios::binary); } + merr.clear(); + CHECK(pk::MappedFile::open(empty, &merr) == nullptr && !merr.empty()); + // The loaders refuse a missing component and a component of another kind before touching any tensor. + CHECK(!pk::CedTagger::load(B, "nope", &merr)); + if (pk::CedTagger::available()) CHECK(merr.find("no component") != std::string::npos); + CHECK(!pk::SpeakerEncoder::load(B, "asr", &merr)); } // --- a VAD-only slice as a "vad" component (interplay with the standalone slice file) --- diff --git a/tests/test_bundle_full.cpp b/tests/test_bundle_full.cpp index ed4b05e..dcbde07 100644 --- a/tests/test_bundle_full.cpp +++ b/tests/test_bundle_full.cpp @@ -2,8 +2,8 @@ // built from: every component gives the same output as its standalone file. // ASR transcript, diarization JSON, CED class scores (bitwise), speaker embedding // (bitwise) and identity, Silero probabilities (bitwise), named diarization with -// bundle components. Also: partial loading, selection rules, and the temporary-file -// fallback of the ced and voice loaders (no file is left behind). +// bundle components. Also: partial loading, selection rules, and that loading a ced or +// voice component makes no temporary file, memory file or descriptor. // // LABEL model; run from the project root (fixtures are relative). // Env (skip 77 unless all are set): @@ -16,12 +16,12 @@ #include #include +#include #include #include #include "audio_io.hpp" #include "bundle.hpp" -#include "bundle_extract.hpp" #include "ced_tagger.hpp" #include "model.hpp" #include "parakeet_capi.h" @@ -41,6 +41,29 @@ static unsigned long long bytes_read() { std::fclose(f); return r; } +static unsigned long long storage_read_bytes() { // bytes this process made the kernel read from storage, mapped files included + std::FILE* f = std::fopen("/proc/self/io", "r"); + if (!f) return 0; + char k[64]; + unsigned long long v = 0, r = 0; + while (std::fscanf(f, "%63s %llu", k, &v) == 2) + if (std::string(k) == "read_bytes:") r = v; + std::fclose(f); + return r; +} +static void drop_page_cache(const char* path) { // clean pages of one file only; no privilege needed + const int fd = ::open(path, O_RDONLY); + if (fd >= 0) { ::posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED); ::close(fd); } +} +static bool maps_contain(const std::string& needle) { // any mapping or file name in /proc/self/maps + std::FILE* f = std::fopen("/proc/self/maps", "r"); + if (!f) return false; + char line[4096]; + bool hit = false; + while (!hit && std::fgets(line, sizeof line, f)) hit = std::strstr(line, needle.c_str()) != nullptr; + std::fclose(f); + return hit; +} static std::string take(char* p) { std::string s = p ? p : ""; if (p) parakeet_capi_free_string(p); @@ -156,23 +179,15 @@ int main() { CHECK(!solo.named_json.empty() && solo.id.rfind("sha256:", 0) == 0); std::printf("transcript: %s\ndiarization: %s\n", solo.transcript.c_str(), solo.diar_json.c_str()); - // ---- the same through the bundle, with the in-memory file and with the temporary-file fallback ---- + // ---- the same through the bundle. The temporary directory is read-only, so a load that tried + // to make a temporary file would fail; no descriptor, memory file or leftover may remain. ---- char tmpl[] = "/tmp/pk_bundle_full_XXXXXX"; const std::string tmpdir = ::mkdtemp(tmpl) ? tmpl : ""; CHECK(!tmpdir.empty()); - for (int pass = 0; pass < 2; ++pass) { - if (pass == 1) { - ::setenv("PARAKEET_BUNDLE_NO_MEMFD", "1", 1); - ::setenv("TMPDIR", tmpdir.c_str(), 1); - } - std::printf("-- pass %d (%s)\n", pass, pass ? "temporary file" : "memfd"); - { - auto cf = pk::ComponentFile::create(bundle, "ced", &err); - CHECK(cf && cf->in_memory() == (pass == 0)); - if (cf && pass == 1) CHECK(count_entries(tmpdir) == 1); - } - CHECK(count_entries(tmpdir) == 0); // the temporary file is gone once the object is - + ::chmod(tmpdir.c_str(), 0500); + ::setenv("TMPDIR", tmpdir.c_str(), 1); + const int cwd0 = count_entries("."); // nothing is created in the working directory either + { auto m = pk::Model::load(bundle, "asr"); CHECK(m != nullptr); if (m) CHECK(m->transcribe_path(speech) == solo.transcript); @@ -181,31 +196,47 @@ int main() { CHECK(d != nullptr); if (d) CHECK(take(parakeet_capi_diarize_path(d, two.c_str())) == solo.diar_json); - const unsigned long long r0 = bytes_read(); + const int fd0 = count_entries("/proc/self/fd"); + drop_page_cache(bundle); + const unsigned long long r0 = bytes_read(), f0 = storage_read_bytes(); std::string le; auto t = pk::CedTagger::load(bundle, "ced", &le); - const unsigned long long r1 = bytes_read(); + const unsigned long long r1 = bytes_read(), f1 = storage_read_bytes(); CHECK(t != nullptr); + CHECK(count_entries("/proc/self/fd") == fd0); // the loaded tagger holds no descriptor const pk::BundleComponent* cc = info.find("ced"); if (t) { std::vector p; pk::SoundScorer sc = t->scorer(); CHECK(sc(a_speech.samples.data(), (int)a_speech.samples.size(), p) && same_bits(p, solo.ced)); - if (r1 > r0 && cc) { - std::printf("ced: read %.1f MB (component %.1f MB, bundle %.1f MB)\n", (double)(r1 - r0) / 1e6, - (double)cc->n_bytes / 1e6, (double)st.st_size / 1e6); - CHECK(r1 - r0 < 3 * cc->n_bytes + (8u << 20)); // copy into the file, then the loader reads it - CHECK(r1 - r0 < (unsigned long long)st.st_size / 2); + // The bundle is mapped, not read(): only read() calls count in rchar (the header), and + // the pages touched through the map count as storage reads once the page cache of the + // file is dropped. Loading must read about the component (plus read-ahead), never the + // other components, which are over half of the bundle. The storage figure is 0 on a + // file system without block I/O accounting; the check is skipped then. + if (cc) { + const unsigned long long f = f1 - f0; + std::printf("ced: %.1f MB read(), %.1f MB read from storage (component %.1f MB, bundle %.1f MB)\n", + (double)(r1 - r0) / 1e6, (double)f / 1e6, (double)cc->n_bytes / 1e6, (double)st.st_size / 1e6); + CHECK(r1 - r0 < (8u << 20)); + CHECK(f < 2 * cc->n_bytes + (8u << 20)); + CHECK(f < (unsigned long long)st.st_size / 2); } } - const unsigned long long q0 = bytes_read(); + const unsigned long long q0 = bytes_read(), g0 = storage_read_bytes(); auto e = pk::SpeakerEncoder::load(bundle, "voice", &le); - const unsigned long long q1 = bytes_read(); + const unsigned long long q1 = bytes_read(), g1 = storage_read_bytes(); CHECK(e != nullptr); + CHECK(count_entries("/proc/self/fd") == fd0); // nor does the encoder if (e) { std::vector v; CHECK(e->embed(a_speech.samples.data(), (int)a_speech.samples.size(), v) && same_bits(v, solo.emb)); - if (q1 > q0 && vc) CHECK(q1 - q0 < 3 * vc->n_bytes + (8u << 20)); + if (vc) { + std::printf("voice: %.1f MB read(), %.1f MB read from storage (component %.1f MB)\n", (double)(q1 - q0) / 1e6, + (double)(g1 - g0) / 1e6, (double)vc->n_bytes / 1e6); + CHECK(q1 - q0 < (8u << 20)); + CHECK(g1 - g0 < 2 * vc->n_bytes + (8u << 20)); + } } parakeet_ctx* sp = parakeet_capi_load_component(bundle, "voice"); CHECK(sp != nullptr); @@ -224,9 +255,12 @@ int main() { parakeet_ctx* tg = parakeet_capi_load_component(bundle, "ced"); CHECK(tg && parakeet_capi_num_classes(tg) > 0 && parakeet_capi_class_label(tg, 0)); parakeet_capi_free(tg); parakeet_capi_free(d); parakeet_capi_free(sp); + // No temporary file, memory file or descriptor is left by any of the loads above. CHECK(count_entries(tmpdir) == 0); + CHECK(count_entries(".") == cwd0); + CHECK(!maps_contain("memfd:") && !maps_contain("parakeet-component") && !maps_contain(tmpdir)); } - ::unsetenv("PARAKEET_BUNDLE_NO_MEMFD"); + ::chmod(tmpdir.c_str(), 0700); ::rmdir(tmpdir.c_str()); if (failures) return 1; diff --git a/third_party/ced.cpp b/third_party/ced.cpp index 61dec2a..736a4ee 160000 --- a/third_party/ced.cpp +++ b/third_party/ced.cpp @@ -1 +1 @@ -Subproject commit 61dec2ab0106f2047ee40062a7075dbf08c523d0 +Subproject commit 736a4ee46a31d4ff38b41add65ce0f2cf1aaa05f diff --git a/third_party/voice-detect.cpp b/third_party/voice-detect.cpp index b74a896..bca46bc 160000 --- a/third_party/voice-detect.cpp +++ b/third_party/voice-detect.cpp @@ -1 +1 @@ -Subproject commit b74a896f47c6d04fcca0a962ff317528fd0b0019 +Subproject commit bca46bcbc2fe68169c2d7c414e9b290a7cb89911