Semantic-neighbor maintenance aborted the daemon during startup: std::clamp<float> asserted '!(__hi < __lo)' because the adaptive cosine threshold could exceed 1.0 for a legal pair of identical vectors. The same work was also admitted while the daemon was still initializing, so the 300-second starvation guard forced a pass before readiness. + semanticInverseNorm/semanticCosineSimilarity/semanticExplicitThreshold in the existing embed policy header, shared by the streamed and corpus branches; non-finite vectors or scores produce no neighbor and legal scores are bounded before ranking and threshold selection + startupMaintenanceEligible() requires ServiceManagerState::Ready, not stopping and idle, and is evaluated before ResourceGovernor::admitDeferrable(), so an elapsed deferral deadline can no longer manufacture missing readiness + StartupPhaseTracker (two atomics, no mutex) plus a warn-level init-waiter line reporting state, phase and phase_elapsed_ms instead of a debug-level state number; a 53-minute startup previously logged nothing actionable + regression through the public backfill and corpus-rebuild APIs using a 64-D vector whose old float inverse norm rounds its self-cosine above 1 - removed the duplicated inverse-norm and cosine lambdas from EmbeddingService - renamed __test_setModelProvider/__test_setKgStore to testing_* because they used reserved identifiers, across 11 files - re-anchored 13 pre-existing raw-environment allowlist entries to their new line numbers; no new exception was added Startup is still blocked by corpus-sized memory-sync scans; this change stops the abort and names the blocking phase, it does not make startup fast. Signed-off-by: trvon <git@trevon.dev>
1104 lines
44 KiB
C++
1104 lines
44 KiB
C++
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iomanip>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <set>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <tuple>
|
|
#include <utility>
|
|
#include <vector>
|
|
#include <yams/compat/unistd.h>
|
|
|
|
#include "../common/test_helpers_catch2.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <numeric>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
|
|
#include "../integration/daemon/test_daemon_harness.h"
|
|
#include "bench_utils.h"
|
|
|
|
#include <yams/app/services/document_ingestion_service.h>
|
|
#include <yams/cli/cli_sync.h>
|
|
#include <yams/daemon/client/daemon_client.h>
|
|
#include <yams/daemon/components/ServiceManager.h>
|
|
#include <yams/daemon/components/TuneAdvisor.h>
|
|
#include <yams/daemon/metric_keys.h>
|
|
#include <yams/daemon/resource/model_provider.h>
|
|
#include <yams/vector/embedding_generator.h>
|
|
#include <yams/vector/simeon_embedding_backend.h>
|
|
|
|
#include <spdlog/spdlog.h>
|
|
|
|
namespace fs = std::filesystem;
|
|
using json = nlohmann::json;
|
|
using yams::app::services::AddOptions;
|
|
using yams::app::services::DocumentIngestionService;
|
|
|
|
namespace {
|
|
|
|
class BenchmarkMockModelProvider final : public yams::daemon::IModelProvider {
|
|
public:
|
|
explicit BenchmarkMockModelProvider(std::size_t dim) : dim_(dim) {}
|
|
|
|
void setProgressCallback(std::function<void(const yams::daemon::ModelLoadEvent&)> cb) override {
|
|
progress_ = std::move(cb);
|
|
}
|
|
|
|
yams::Result<std::vector<float>> generateEmbedding(const std::string& text) override {
|
|
return generateEmbeddingFor(defaultModelName_, text);
|
|
}
|
|
|
|
yams::Result<std::vector<std::vector<float>>>
|
|
generateBatchEmbeddings(const std::vector<std::string>& texts) override {
|
|
return generateBatchEmbeddingsFor(defaultModelName_, texts);
|
|
}
|
|
|
|
yams::Result<std::vector<float>> generateEmbeddingFor(const std::string& modelName,
|
|
const std::string& text) override {
|
|
if (!isModelLoaded(modelName)) {
|
|
return yams::Error{yams::ErrorCode::NotFound, "model not loaded"};
|
|
}
|
|
std::vector<float> embedding(dim_, 0.0f);
|
|
std::hash<std::string> hasher;
|
|
const auto seed = hasher(text);
|
|
for (std::size_t i = 0; i < embedding.size(); ++i) {
|
|
embedding[i] = static_cast<float>((seed ^ (i * 131)) % 1000) / 1000.0f;
|
|
}
|
|
return embedding;
|
|
}
|
|
|
|
yams::Result<std::vector<std::vector<float>>>
|
|
generateBatchEmbeddingsFor(const std::string& modelName,
|
|
const std::vector<std::string>& texts) override {
|
|
if (!isModelLoaded(modelName)) {
|
|
return yams::Error{yams::ErrorCode::NotFound, "model not loaded"};
|
|
}
|
|
std::vector<std::vector<float>> batch;
|
|
batch.reserve(texts.size());
|
|
for (const auto& text : texts) {
|
|
auto embedding = generateEmbeddingFor(modelName, text);
|
|
if (!embedding) {
|
|
return embedding.error();
|
|
}
|
|
batch.push_back(std::move(embedding.value()));
|
|
}
|
|
return batch;
|
|
}
|
|
|
|
yams::Result<void> loadModel(const std::string& modelName) override {
|
|
if (progress_) {
|
|
yams::daemon::ModelLoadEvent ev;
|
|
ev.modelName = modelName;
|
|
ev.phase = "loading";
|
|
ev.message = "benchmark mock loading";
|
|
progress_(ev);
|
|
}
|
|
loadedModels_.insert(modelName);
|
|
defaultModelName_ = modelName;
|
|
if (progress_) {
|
|
yams::daemon::ModelLoadEvent ev;
|
|
ev.modelName = modelName;
|
|
ev.phase = "completed";
|
|
ev.message = "benchmark mock ready";
|
|
progress_(ev);
|
|
}
|
|
return yams::Result<void>();
|
|
}
|
|
|
|
yams::Result<void> unloadModel(const std::string& modelName) override {
|
|
loadedModels_.erase(modelName);
|
|
return yams::Result<void>();
|
|
}
|
|
|
|
bool isModelLoaded(const std::string& modelName) const override {
|
|
return loadedModels_.contains(modelName);
|
|
}
|
|
|
|
std::vector<std::string> getLoadedModels() const override {
|
|
return std::vector<std::string>(loadedModels_.begin(), loadedModels_.end());
|
|
}
|
|
|
|
size_t getLoadedModelCount() const override { return loadedModels_.size(); }
|
|
|
|
yams::Result<yams::daemon::ModelInfo>
|
|
getModelInfo(const std::string& modelName) const override {
|
|
if (!isModelLoaded(modelName)) {
|
|
return yams::Error{yams::ErrorCode::NotFound, "model not loaded"};
|
|
}
|
|
yams::daemon::ModelInfo info;
|
|
info.name = modelName;
|
|
info.embeddingDim = dim_;
|
|
return info;
|
|
}
|
|
|
|
size_t getEmbeddingDim(const std::string&) const override { return dim_; }
|
|
std::shared_ptr<yams::vector::EmbeddingGenerator>
|
|
getEmbeddingGenerator(const std::string& = "") override {
|
|
return nullptr;
|
|
}
|
|
std::string getProviderName() const override { return "BenchmarkMockProvider"; }
|
|
std::string getProviderVersion() const override { return "1.0"; }
|
|
bool isAvailable() const override { return true; }
|
|
size_t getMemoryUsage() const override { return 0; }
|
|
void releaseUnusedResources() override {}
|
|
void shutdown() override { loadedModels_.clear(); }
|
|
|
|
private:
|
|
std::size_t dim_;
|
|
std::string defaultModelName_;
|
|
std::unordered_set<std::string> loadedModels_;
|
|
std::function<void(const yams::daemon::ModelLoadEvent&)> progress_;
|
|
};
|
|
|
|
struct RunConfig {
|
|
std::string label;
|
|
int workers{1};
|
|
int repeat{1};
|
|
bool enableEmbeddings{false};
|
|
bool requireTopologyFresh{false};
|
|
bool requireSearchProbe{false};
|
|
std::string embeddingModel{"simeon-default"};
|
|
int searchProbeTimeoutSecs{120};
|
|
std::vector<std::string> args;
|
|
};
|
|
|
|
struct BenchConfig {
|
|
fs::path datasetPath;
|
|
fs::path manifestPath;
|
|
fs::path metricsPath;
|
|
bool postRunCleanup{true};
|
|
std::vector<RunConfig> runs;
|
|
};
|
|
|
|
json loadJsonFile(const fs::path& path) {
|
|
std::ifstream in(path);
|
|
if (!in) {
|
|
throw std::runtime_error("Failed to open config file: " + path.string());
|
|
}
|
|
json data;
|
|
in >> data;
|
|
return data;
|
|
}
|
|
|
|
fs::path normalizePath(const fs::path& base, const fs::path& value) {
|
|
fs::path resolved = value;
|
|
if (resolved.is_relative()) {
|
|
resolved = base / resolved;
|
|
}
|
|
return fs::absolute(resolved).lexically_normal();
|
|
}
|
|
|
|
BenchConfig loadConfig(const fs::path& configPath) {
|
|
const json root = loadJsonFile(configPath);
|
|
BenchConfig cfg;
|
|
|
|
const fs::path base =
|
|
configPath.parent_path().empty() ? fs::current_path() : configPath.parent_path();
|
|
|
|
cfg.datasetPath =
|
|
normalizePath(base, root.value("dataset_path", std::string("tests/data/ingestion")));
|
|
cfg.manifestPath = normalizePath(
|
|
base, root.value("fixture_manifest", std::string("data/benchmarks/fixtures.json")));
|
|
cfg.metricsPath =
|
|
normalizePath(base, root.value("output_metrics",
|
|
std::string("data/benchmarks/ingestion_baseline.jsonl")));
|
|
cfg.postRunCleanup = root.value("post_run_cleanup", true);
|
|
|
|
for (const auto& entry : root.at("runs")) {
|
|
RunConfig run;
|
|
run.label = entry.value("label", std::string("baseline"));
|
|
run.workers = entry.value("workers", 1);
|
|
run.repeat = std::max(1, entry.value("repeat", 1));
|
|
run.enableEmbeddings = entry.value("enable_embeddings", false);
|
|
run.requireTopologyFresh = entry.value("require_topology_fresh", run.enableEmbeddings);
|
|
run.embeddingModel = entry.value("embedding_model", std::string("simeon-default"));
|
|
if (entry.contains("args")) {
|
|
for (const auto& arg : entry["args"]) {
|
|
run.args.push_back(arg.get<std::string>());
|
|
}
|
|
}
|
|
cfg.runs.push_back(std::move(run));
|
|
}
|
|
|
|
return cfg;
|
|
}
|
|
|
|
using yams::bench::isoTimestamp;
|
|
|
|
std::vector<std::string> splitCsv(const std::string& csv) {
|
|
std::vector<std::string> tokens;
|
|
std::string current;
|
|
std::istringstream iss(csv);
|
|
while (std::getline(iss, current, ',')) {
|
|
if (!current.empty()) {
|
|
tokens.push_back(current);
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
struct IngestOptions {
|
|
bool recursive{true};
|
|
bool verify{false};
|
|
std::vector<std::string> includePatterns;
|
|
std::vector<std::string> excludePatterns;
|
|
std::vector<std::string> tags;
|
|
std::map<std::string, std::string> metadata;
|
|
std::string collection;
|
|
};
|
|
|
|
IngestOptions parseArgs(const std::vector<std::string>& args) {
|
|
IngestOptions opts;
|
|
for (const auto& arg : args) {
|
|
if (arg == "--recursive") {
|
|
opts.recursive = true;
|
|
} else if (arg == "--no-recursive") {
|
|
opts.recursive = false;
|
|
} else if (arg.rfind("--include=", 0) == 0) {
|
|
auto val = arg.substr(std::string("--include=").size());
|
|
auto patterns = splitCsv(val);
|
|
opts.includePatterns.insert(opts.includePatterns.end(), patterns.begin(),
|
|
patterns.end());
|
|
} else if (arg.rfind("--exclude=", 0) == 0) {
|
|
auto val = arg.substr(std::string("--exclude=").size());
|
|
auto patterns = splitCsv(val);
|
|
opts.excludePatterns.insert(opts.excludePatterns.end(), patterns.begin(),
|
|
patterns.end());
|
|
} else if (arg.rfind("--tags=", 0) == 0) {
|
|
auto val = arg.substr(std::string("--tags=").size());
|
|
auto tags = splitCsv(val);
|
|
opts.tags.insert(opts.tags.end(), tags.begin(), tags.end());
|
|
} else if (arg.rfind("--collection=", 0) == 0) {
|
|
opts.collection = arg.substr(std::string("--collection=").size());
|
|
} else if (arg == "--verify") {
|
|
opts.verify = true;
|
|
} else if (arg == "--no-verify") {
|
|
opts.verify = false;
|
|
} else if (arg.rfind("--metadata=", 0) == 0) {
|
|
auto kv = arg.substr(std::string("--metadata=").size());
|
|
auto pos = kv.find('=');
|
|
if (pos != std::string::npos) {
|
|
auto key = kv.substr(0, pos);
|
|
auto value = kv.substr(pos + 1);
|
|
if (!key.empty()) {
|
|
opts.metadata.emplace(std::move(key), std::move(value));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
size_t countFiles(const fs::path& root) {
|
|
if (!fs::exists(root)) {
|
|
throw std::runtime_error("Dataset directory does not exist: " + root.string());
|
|
}
|
|
size_t total = 0;
|
|
for (const auto& entry : fs::recursive_directory_iterator(root)) {
|
|
if (entry.is_regular_file()) {
|
|
++total;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
void ensureMetricsPath(const fs::path& path) {
|
|
if (!path.parent_path().empty()) {
|
|
fs::create_directories(path.parent_path());
|
|
}
|
|
std::ofstream touch(path, std::ios::app);
|
|
if (!touch) {
|
|
throw std::runtime_error("Failed to open metrics output: " + path.string());
|
|
}
|
|
}
|
|
|
|
void deleteTree(const fs::path& root) {
|
|
std::error_code ec;
|
|
fs::remove_all(root, ec);
|
|
if (ec) {
|
|
std::cerr << "[bench] Warning: failed to remove " << root << ": " << ec.message() << "\n";
|
|
}
|
|
}
|
|
|
|
using ScopedEnv = yams::test::ScopedEnvVar;
|
|
|
|
struct StatusSnapshot {
|
|
uint64_t documentsTotal{0};
|
|
uint64_t documentsIndexed{0};
|
|
uint64_t workerQueued{0};
|
|
uint64_t workerActive{0};
|
|
uint64_t postIngestQueued{0};
|
|
uint64_t postIngestInflight{0};
|
|
uint64_t postIngestProcessed{0};
|
|
uint64_t postIngestFailed{0};
|
|
uint64_t embedQueued{0};
|
|
uint64_t embedInflight{0};
|
|
uint64_t topologyDirtyDocuments{0};
|
|
uint64_t topologyLastSuccessAgeMs{0};
|
|
uint64_t topologyRebuildLagMs{0};
|
|
uint64_t topologyRebuildRunningAgeMs{0};
|
|
uint64_t topologyLastDurationMs{0};
|
|
uint64_t topologyRebuildsTotal{0};
|
|
uint64_t topologyRebuildFailuresTotal{0};
|
|
uint64_t topologyLastDocumentsRequested{0};
|
|
uint64_t topologyLastDocumentsProcessed{0};
|
|
uint64_t topologyLastClustersBuilt{0};
|
|
uint64_t topologyLastMembershipsBuilt{0};
|
|
uint64_t topologyLastDirtySeedCount{0};
|
|
uint64_t topologyLastDirtyRegionDocs{0};
|
|
uint64_t topologyLastCoalescedDirtySets{0};
|
|
uint64_t topologyLastFallbackFullRebuilds{0};
|
|
bool topologyArtifactsFresh{false};
|
|
bool topologyRebuildRunning{false};
|
|
bool simeonLexicalReady{false};
|
|
bool simeonLexicalConfigured{false};
|
|
|
|
[[nodiscard]] bool pipelineIdle(bool requireTopologyFresh) const {
|
|
const bool topologyIdle =
|
|
!requireTopologyFresh ||
|
|
(topologyArtifactsFresh && !topologyRebuildRunning && topologyDirtyDocuments == 0);
|
|
return workerQueued == 0 && workerActive == 0 && postIngestQueued == 0 &&
|
|
postIngestInflight == 0 && topologyIdle;
|
|
}
|
|
|
|
[[nodiscard]] bool searchable(bool requireTopologyFresh) const {
|
|
return pipelineIdle(requireTopologyFresh) && embedQueued == 0 && embedInflight == 0 &&
|
|
(!requireTopologyFresh || (topologyArtifactsFresh && !topologyRebuildRunning &&
|
|
topologyDirtyDocuments == 0)) &&
|
|
(!simeonLexicalConfigured || simeonLexicalReady);
|
|
}
|
|
|
|
static StatusSnapshot capture(yams::daemon::DaemonClient& client) {
|
|
StatusSnapshot snapshot;
|
|
auto statusResult = yams::cli::run_sync(client.status(), std::chrono::seconds(5));
|
|
if (!statusResult) {
|
|
return snapshot;
|
|
}
|
|
|
|
const auto& status = statusResult.value();
|
|
auto getCount = [&](std::string_view key) -> uint64_t {
|
|
auto it = status.requestCounts.find(std::string(key));
|
|
return it != status.requestCounts.end() ? it->second : 0;
|
|
};
|
|
auto getReady = [&](std::string_view key) -> bool {
|
|
auto it = status.readinessStates.find(std::string(key));
|
|
return it != status.readinessStates.end() ? it->second : false;
|
|
};
|
|
|
|
snapshot.documentsTotal = getCount(yams::daemon::metrics::kDocumentsTotal);
|
|
snapshot.documentsIndexed = getCount(yams::daemon::metrics::kDocumentsIndexed);
|
|
snapshot.workerQueued = getCount(yams::daemon::metrics::kWorkerQueued);
|
|
snapshot.workerActive = getCount(yams::daemon::metrics::kWorkerActive);
|
|
snapshot.postIngestQueued = getCount(yams::daemon::metrics::kPostIngestQueued);
|
|
snapshot.postIngestInflight = getCount(yams::daemon::metrics::kPostIngestInflight);
|
|
snapshot.postIngestProcessed = getCount(yams::daemon::metrics::kPostIngestProcessed);
|
|
snapshot.postIngestFailed = getCount(yams::daemon::metrics::kPostIngestFailed);
|
|
snapshot.embedQueued = getCount(yams::daemon::metrics::kEmbedQueued);
|
|
snapshot.embedInflight = getCount(yams::daemon::metrics::kEmbedInflight);
|
|
snapshot.topologyDirtyDocuments = getCount(yams::daemon::metrics::kTopologyDirtyDocuments);
|
|
snapshot.topologyLastSuccessAgeMs =
|
|
getCount(yams::daemon::metrics::kTopologyLastSuccessAgeMs);
|
|
snapshot.topologyRebuildLagMs = getCount(yams::daemon::metrics::kTopologyRebuildLagMs);
|
|
snapshot.topologyRebuildRunningAgeMs =
|
|
getCount(yams::daemon::metrics::kTopologyRebuildRunningAgeMs);
|
|
snapshot.topologyLastDurationMs = getCount(yams::daemon::metrics::kTopologyLastDurationMs);
|
|
snapshot.topologyRebuildsTotal = getCount(yams::daemon::metrics::kTopologyRebuildsTotal);
|
|
snapshot.topologyRebuildFailuresTotal =
|
|
getCount(yams::daemon::metrics::kTopologyRebuildFailuresTotal);
|
|
snapshot.topologyLastDocumentsRequested =
|
|
getCount(yams::daemon::metrics::kTopologyLastDocumentsRequested);
|
|
snapshot.topologyLastDocumentsProcessed =
|
|
getCount(yams::daemon::metrics::kTopologyLastDocumentsProcessed);
|
|
snapshot.topologyLastClustersBuilt =
|
|
getCount(yams::daemon::metrics::kTopologyLastClustersBuilt);
|
|
snapshot.topologyLastMembershipsBuilt =
|
|
getCount(yams::daemon::metrics::kTopologyLastMembershipsBuilt);
|
|
snapshot.topologyLastDirtySeedCount =
|
|
getCount(yams::daemon::metrics::kTopologyLastDirtySeedCount);
|
|
snapshot.topologyLastDirtyRegionDocs =
|
|
getCount(yams::daemon::metrics::kTopologyLastDirtyRegionDocs);
|
|
snapshot.topologyLastCoalescedDirtySets =
|
|
getCount(yams::daemon::metrics::kTopologyLastCoalescedDirtySets);
|
|
snapshot.topologyLastFallbackFullRebuilds =
|
|
getCount(yams::daemon::metrics::kTopologyLastFallbackFullRebuilds);
|
|
snapshot.topologyArtifactsFresh =
|
|
getReady(yams::daemon::readiness::kTopologyArtifactsFresh);
|
|
snapshot.topologyRebuildRunning =
|
|
getReady(yams::daemon::readiness::kTopologyRebuildRunning);
|
|
snapshot.simeonLexicalConfigured =
|
|
getReady(yams::daemon::readiness::kSearchEngineLexicalEnhancementConfigured);
|
|
snapshot.simeonLexicalReady =
|
|
getReady(yams::daemon::readiness::kSearchEngineLexicalEnhancementReady);
|
|
return snapshot;
|
|
}
|
|
};
|
|
|
|
bool probeDaemonSearch(yams::daemon::DaemonClient& client, const std::string& query,
|
|
const std::string& searchType, const std::string& expectedPath) {
|
|
using namespace yams::daemon;
|
|
SearchRequest req;
|
|
req.query = query;
|
|
req.searchType = searchType;
|
|
req.limit = 5;
|
|
req.pathsOnly = true;
|
|
req.timeout = std::chrono::milliseconds(5000);
|
|
auto result = yams::cli::run_sync(client.search(req), std::chrono::seconds(6));
|
|
if (!result)
|
|
return false;
|
|
for (const auto& r : result.value().results) {
|
|
if (expectedPath.empty())
|
|
return true;
|
|
if (!r.path.empty() && r.path.find(expectedPath) != std::string::npos)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static std::string randomToken() {
|
|
static const char* cs = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
thread_local std::mt19937_64 rng{std::random_device{}()};
|
|
std::uniform_int_distribution<size_t> dist(0, 35);
|
|
std::string out;
|
|
for (int i = 0; i < 16; ++i)
|
|
out.push_back(cs[dist(rng)]);
|
|
return out;
|
|
}
|
|
|
|
std::string createSentinelFile(const fs::path& rootDir, std::string& tokenOut) {
|
|
tokenOut = "yams_bench_probe_" + randomToken();
|
|
fs::path sentinelPath = rootDir / ".yams_bench_probe.txt";
|
|
std::error_code ec;
|
|
fs::create_directories(rootDir, ec);
|
|
std::ofstream out(sentinelPath);
|
|
if (!out) {
|
|
throw std::runtime_error("Failed to create sentinel file: " + sentinelPath.string());
|
|
}
|
|
out << "YAMS_BENCH_SENTINEL_TOKEN: " << tokenOut << "\n";
|
|
out << "This file exists solely for the ingestion throughput benchmark search probe.\n";
|
|
return sentinelPath.string();
|
|
}
|
|
|
|
struct SearchableWaitResult {
|
|
bool completed{false};
|
|
bool keywordHit{false};
|
|
bool hybridHit{false};
|
|
StatusSnapshot lastSnapshot{};
|
|
std::chrono::nanoseconds keywordHitAt{};
|
|
std::chrono::nanoseconds hybridHitAt{};
|
|
std::chrono::nanoseconds drainedAt{};
|
|
std::chrono::nanoseconds searchableAt{};
|
|
std::string probeQuery;
|
|
std::string probeExpectedPath;
|
|
};
|
|
|
|
SearchableWaitResult waitForSearchable(yams::daemon::DaemonClient& client,
|
|
std::chrono::seconds timeout, bool requireTopologyFresh,
|
|
bool requireSearchProbe, const std::string& probeToken,
|
|
const std::string& probeExpectedPath) {
|
|
auto deadline = std::chrono::steady_clock::now() + timeout;
|
|
SearchableWaitResult result;
|
|
result.probeQuery = probeToken;
|
|
result.probeExpectedPath = probeExpectedPath;
|
|
StatusSnapshot previous;
|
|
StatusSnapshot last;
|
|
bool havePrevious = false;
|
|
int stableCount = 0;
|
|
constexpr int kStableRequired = 3;
|
|
bool keywordFound = !requireSearchProbe;
|
|
bool hybridFound = !requireSearchProbe;
|
|
const auto start = std::chrono::steady_clock::now();
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
StatusSnapshot current = StatusSnapshot::capture(client);
|
|
last = current;
|
|
|
|
if (!keywordFound && requireSearchProbe && !probeToken.empty()) {
|
|
if (probeDaemonSearch(client, probeToken, "keyword", probeExpectedPath)) {
|
|
keywordFound = true;
|
|
if (result.keywordHitAt == std::chrono::nanoseconds{}) {
|
|
result.keywordHitAt = std::chrono::steady_clock::now() - start;
|
|
}
|
|
}
|
|
}
|
|
if (!hybridFound && requireSearchProbe && !probeToken.empty() && keywordFound) {
|
|
if (probeDaemonSearch(client, probeToken, "hybrid", probeExpectedPath)) {
|
|
hybridFound = true;
|
|
if (result.hybridHitAt == std::chrono::nanoseconds{}) {
|
|
result.hybridHitAt = std::chrono::steady_clock::now() - start;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool searchable = current.documentsTotal > 0 && current.searchable(requireTopologyFresh);
|
|
if (searchable && havePrevious) {
|
|
searchable = current.documentsTotal == previous.documentsTotal &&
|
|
current.documentsIndexed == previous.documentsIndexed &&
|
|
current.postIngestProcessed == previous.postIngestProcessed &&
|
|
current.postIngestFailed == previous.postIngestFailed &&
|
|
current.topologyRebuildsTotal == previous.topologyRebuildsTotal &&
|
|
current.topologyDirtyDocuments == previous.topologyDirtyDocuments;
|
|
}
|
|
|
|
if (searchable) {
|
|
++stableCount;
|
|
if (stableCount >= kStableRequired && keywordFound && hybridFound) {
|
|
result.completed = true;
|
|
result.lastSnapshot = current;
|
|
result.keywordHit = keywordFound;
|
|
result.hybridHit = hybridFound;
|
|
result.searchableAt = std::chrono::steady_clock::now() - start;
|
|
return result;
|
|
}
|
|
} else {
|
|
stableCount = 0;
|
|
}
|
|
|
|
previous = current;
|
|
havePrevious = true;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
|
}
|
|
|
|
result.lastSnapshot = last;
|
|
result.keywordHit = keywordFound;
|
|
result.hybridHit = hybridFound;
|
|
return result;
|
|
}
|
|
|
|
bool useMockEmbeddingsForBench() {
|
|
if (const char* env = std::getenv("YAMS_BENCH_FORCE_MOCK_EMBEDDINGS")) {
|
|
return std::string(env) == "1";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::optional<fs::path> writeMockEmbeddingConfig(const fs::path& root) {
|
|
std::error_code ec;
|
|
fs::create_directories(root, ec);
|
|
const fs::path cfgPath = root / "mock_embeddings.toml";
|
|
std::ofstream out(cfgPath);
|
|
if (!out) {
|
|
return std::nullopt;
|
|
}
|
|
out << "[embeddings]\n";
|
|
out << "embedding_dim = 384\n";
|
|
out << "preferred_model = \"all-MiniLM-L6-v2\"\n";
|
|
return cfgPath;
|
|
}
|
|
|
|
void ensureBenchmarkEmbeddingsReady(yams::daemon::YamsDaemon* daemon, bool enableEmbeddings,
|
|
bool useMockEmbeddings, const std::string& embeddingModel) {
|
|
if (!enableEmbeddings || !daemon) {
|
|
return;
|
|
}
|
|
|
|
auto serviceManager = daemon->getServiceManager();
|
|
if (!serviceManager) {
|
|
return;
|
|
}
|
|
|
|
if (useMockEmbeddings) {
|
|
std::size_t dim = 384;
|
|
if (auto vectorDb = serviceManager->getVectorDatabase()) {
|
|
dim = vectorDb->getConfig().embedding_dim;
|
|
}
|
|
auto sharedProvider =
|
|
std::make_shared<BenchmarkMockModelProvider>(std::max<std::size_t>(dim, 1));
|
|
serviceManager->testing_setModelProvider(sharedProvider);
|
|
}
|
|
|
|
const std::string model = embeddingModel.empty() ? "simeon-default" : embeddingModel;
|
|
auto ready = serviceManager->ensureEmbeddingModelReadySync(model, {}, 10000, false, false);
|
|
if (!ready) {
|
|
spdlog::warn("[bench] Embedding model not ready for throughput run: {}",
|
|
ready.error().message);
|
|
}
|
|
}
|
|
|
|
struct RunResult {
|
|
int exitCode{0};
|
|
double durationSeconds{0.0};
|
|
double throughputFilesPerSecond{0.0};
|
|
double timeToKeywordHitMs{0.0};
|
|
double timeToHybridHitMs{0.0};
|
|
double timeToDrainedMs{0.0};
|
|
double timeToSearchableMs{0.0};
|
|
std::string command;
|
|
fs::path dataDir;
|
|
StatusSnapshot finalSnapshot{};
|
|
bool drained{false};
|
|
bool timedOut{false};
|
|
bool keywordHit{false};
|
|
bool hybridHit{false};
|
|
bool topologyValidated{false};
|
|
std::string probeToken;
|
|
std::string probeExpectedPath;
|
|
std::uint64_t addDocumentsAdded{0};
|
|
std::string addMessage;
|
|
std::uint64_t embedQueued{0};
|
|
std::uint64_t embedConsumed{0};
|
|
std::uint64_t embedDropped{0};
|
|
std::uint64_t embedPreparedDocsQueued{0};
|
|
std::uint64_t embedPreparedChunksQueued{0};
|
|
std::uint64_t embedHashOnlyDocsQueued{0};
|
|
std::uint64_t postQueued{0};
|
|
std::uint64_t postConsumed{0};
|
|
std::uint64_t postDropped{0};
|
|
std::uint64_t kgQueued{0};
|
|
std::uint64_t kgConsumed{0};
|
|
std::uint64_t kgDropped{0};
|
|
std::uint64_t symbolQueued{0};
|
|
std::uint64_t symbolConsumed{0};
|
|
std::uint64_t symbolDropped{0};
|
|
std::uint64_t entityQueued{0};
|
|
std::uint64_t entityConsumed{0};
|
|
std::uint64_t entityDropped{0};
|
|
std::uint64_t titleQueued{0};
|
|
std::uint64_t titleConsumed{0};
|
|
std::uint64_t titleDropped{0};
|
|
};
|
|
|
|
RunResult executeRun(const BenchConfig& cfg, const RunConfig& run, size_t datasetCount,
|
|
int iteration) {
|
|
(void)datasetCount;
|
|
|
|
fs::path runRoot = yams::test::make_temp_dir("yams_ingest_bench_") /
|
|
(run.label + "_" + std::to_string(iteration));
|
|
fs::path runDataDir = runRoot / "data";
|
|
fs::create_directories(runDataDir);
|
|
|
|
std::string workerStr = std::to_string(std::max(1, run.workers));
|
|
ScopedEnv envWorkers("YAMS_INDEXING_WORKERS", workerStr);
|
|
ScopedEnv envLegacy("YAMS_BENCH_INDEX_WORKERS", workerStr);
|
|
ScopedEnv envParallel("YAMS_ENABLE_PARALLEL_INGEST", "1");
|
|
ScopedEnv envPoolSize("YAMS_STORAGE_POOL_SIZE", "8");
|
|
ScopedEnv envSafeSingle("YAMS_TEST_SAFE_SINGLE_INSTANCE", "1");
|
|
const bool useMockEmbeddings = useMockEmbeddingsForBench();
|
|
std::optional<ScopedEnv> envDisableVectors;
|
|
if (!run.enableEmbeddings) {
|
|
envDisableVectors.emplace("YAMS_DISABLE_VECTORS", "1");
|
|
}
|
|
ScopedEnv envDisableWatcher("YAMS_DISABLE_SESSION_WATCHER", "1");
|
|
std::optional<ScopedEnv> envSkipModelLoading;
|
|
if (!run.enableEmbeddings) {
|
|
envSkipModelLoading.emplace("YAMS_SKIP_MODEL_LOADING", "1");
|
|
}
|
|
std::optional<ScopedEnv> envUseMockProvider;
|
|
std::optional<ScopedEnv> envEmbedBackend;
|
|
const bool useSimeon = run.embeddingModel == "simeon-default" || run.embeddingModel == "simeon";
|
|
if (run.enableEmbeddings && !useMockEmbeddings && useSimeon) {
|
|
envEmbedBackend.emplace("YAMS_EMBED_BACKEND", "simeon");
|
|
}
|
|
if (run.enableEmbeddings && useMockEmbeddings) {
|
|
envUseMockProvider.emplace("YAMS_USE_MOCK_PROVIDER", "1");
|
|
}
|
|
std::optional<ScopedEnv> envMockEmbedDim;
|
|
if (run.enableEmbeddings && useMockEmbeddings) {
|
|
envMockEmbedDim.emplace("YAMS_EMBED_DIM", "384");
|
|
}
|
|
std::optional<fs::path> benchConfigPath;
|
|
if (run.enableEmbeddings && useMockEmbeddings) {
|
|
benchConfigPath = writeMockEmbeddingConfig(runRoot);
|
|
}
|
|
|
|
yams::daemon::TuneAdvisor::setEnableParallelIngest(true);
|
|
yams::daemon::TuneAdvisor::setMaxIngestWorkers(static_cast<uint32_t>(std::max(1, run.workers)));
|
|
yams::daemon::TuneAdvisor::setStoragePoolSize(8);
|
|
|
|
const auto opts = parseArgs(run.args);
|
|
|
|
yams::test::DaemonHarness::Options harnessOptions;
|
|
harnessOptions.enableModelProvider = true;
|
|
harnessOptions.useMockModelProvider = !run.enableEmbeddings || useMockEmbeddings;
|
|
harnessOptions.autoLoadPlugins = run.enableEmbeddings && !useMockEmbeddings;
|
|
harnessOptions.configureModelPool = run.enableEmbeddings && !useMockEmbeddings;
|
|
harnessOptions.modelPoolLazyLoading = false;
|
|
if (run.enableEmbeddings && !useMockEmbeddings) {
|
|
harnessOptions.preloadModels = {useSimeon ? std::string{"simeon-default"}
|
|
: run.embeddingModel};
|
|
if (const char* envPluginDir = std::getenv("YAMS_PLUGIN_DIR")) {
|
|
harnessOptions.pluginDir = fs::path(envPluginDir);
|
|
} else {
|
|
harnessOptions.pluginDir = fs::current_path() / "build/debug" / "plugins";
|
|
}
|
|
}
|
|
harnessOptions.enableAutoRepair = false;
|
|
harnessOptions.isolateState = true;
|
|
harnessOptions.dataDir = runDataDir;
|
|
if (benchConfigPath.has_value()) {
|
|
harnessOptions.configPath = *benchConfigPath;
|
|
}
|
|
|
|
yams::test::DaemonHarness harness(harnessOptions);
|
|
if (!harness.start(std::chrono::seconds(30), [](yams::daemon::YamsDaemon*) {})) {
|
|
throw std::runtime_error("Failed to start daemon harness for ingestion benchmark");
|
|
}
|
|
ensureBenchmarkEmbeddingsReady(harness.daemon(), run.enableEmbeddings, useMockEmbeddings,
|
|
useSimeon ? std::string{"simeon-default"} : run.embeddingModel);
|
|
|
|
yams::daemon::ClientConfig clientCfg;
|
|
clientCfg.socketPath = harness.socketPath();
|
|
clientCfg.autoStart = false;
|
|
yams::daemon::DaemonClient client(clientCfg);
|
|
|
|
std::string probeToken;
|
|
std::string probeExpectedPath;
|
|
if (run.requireSearchProbe) {
|
|
probeExpectedPath = createSentinelFile(runRoot, probeToken);
|
|
spdlog::info("[bench] Search probe: token={} path={}", probeToken, probeExpectedPath);
|
|
}
|
|
|
|
DocumentIngestionService ingestion;
|
|
AddOptions addOptions;
|
|
addOptions.socketPath = harness.socketPath();
|
|
addOptions.explicitDataDir = harness.dataDir();
|
|
addOptions.path = cfg.datasetPath.string();
|
|
addOptions.recursive = opts.recursive;
|
|
addOptions.includePatterns = opts.includePatterns;
|
|
addOptions.excludePatterns = opts.excludePatterns;
|
|
addOptions.tags = opts.tags;
|
|
addOptions.metadata = opts.metadata;
|
|
addOptions.collection = opts.collection;
|
|
addOptions.verify = opts.verify;
|
|
addOptions.noEmbeddings = !run.enableEmbeddings;
|
|
addOptions.timeoutMs = 30000;
|
|
addOptions.retries = 2;
|
|
|
|
int searchableTimeoutSecs = run.searchProbeTimeoutSecs;
|
|
if (const char* envDrain = std::getenv("YAMS_BENCH_DRAIN_TIMEOUT_S"); envDrain && *envDrain) {
|
|
try {
|
|
searchableTimeoutSecs = std::max(10, std::stoi(envDrain));
|
|
} catch (...) {
|
|
}
|
|
}
|
|
|
|
auto& bus = yams::daemon::InternalEventBus::instance();
|
|
const auto baseEmbedQueued = bus.embedQueued();
|
|
const auto baseEmbedConsumed = bus.embedConsumed();
|
|
const auto baseEmbedDropped = bus.embedDropped();
|
|
const auto baseEmbedPreparedDocsQueued = bus.embedPreparedDocsQueued();
|
|
const auto baseEmbedPreparedChunksQueued = bus.embedPreparedChunksQueued();
|
|
const auto baseEmbedHashOnlyDocsQueued = bus.embedHashOnlyDocsQueued();
|
|
const auto basePostQueued = bus.postQueued();
|
|
const auto basePostConsumed = bus.postConsumed();
|
|
const auto basePostDropped = bus.postDropped();
|
|
const auto baseKgQueued = bus.kgQueued();
|
|
const auto baseKgConsumed = bus.kgConsumed();
|
|
const auto baseKgDropped = bus.kgDropped();
|
|
const auto baseEntityQueued = bus.entityQueued();
|
|
const auto baseEntityConsumed = bus.entityConsumed();
|
|
const auto baseEntityDropped = bus.entityDropped();
|
|
const auto baseTitleQueued = bus.titleQueued();
|
|
const auto baseTitleConsumed = bus.titleConsumed();
|
|
const auto baseTitleDropped = bus.titleDropped();
|
|
|
|
if (run.requireSearchProbe && !probeToken.empty()) {
|
|
AddOptions sentinelOpts = addOptions;
|
|
sentinelOpts.path = probeExpectedPath;
|
|
sentinelOpts.recursive = false;
|
|
auto sentinelResult = ingestion.addViaDaemon(sentinelOpts);
|
|
if (!sentinelResult) {
|
|
throw std::runtime_error("Sentinel ingest failed: " + sentinelResult.error().message);
|
|
}
|
|
spdlog::info("[bench] Sentinel document ingested: {}", probeToken);
|
|
}
|
|
|
|
const auto start = std::chrono::steady_clock::now();
|
|
auto addResult = ingestion.addViaDaemon(addOptions);
|
|
if (!addResult) {
|
|
throw std::runtime_error("Daemon ingestion failed: " + addResult.error().message);
|
|
}
|
|
auto waitResult = waitForSearchable(client, std::chrono::seconds(searchableTimeoutSecs),
|
|
run.requireTopologyFresh, run.requireSearchProbe,
|
|
probeToken, probeExpectedPath);
|
|
const auto end = std::chrono::steady_clock::now();
|
|
|
|
harness.stop();
|
|
|
|
const double elapsed = std::chrono::duration<double>(end - start).count();
|
|
const uint64_t documentsTotal = waitResult.lastSnapshot.documentsTotal;
|
|
const double throughput =
|
|
(documentsTotal > 0 && elapsed > 0.0) ? static_cast<double>(documentsTotal) / elapsed : 0.0;
|
|
|
|
auto nsToMs = [](std::chrono::nanoseconds ns) -> double {
|
|
return ns.count() > 0 ? std::chrono::duration<double, std::milli>(ns).count() : 0.0;
|
|
};
|
|
|
|
std::ostringstream cmd;
|
|
cmd << "daemon_ingest_full_pipeline label=" << run.label << " workers=" << run.workers
|
|
<< " embeddings=" << (run.enableEmbeddings ? 1 : 0);
|
|
if (!opts.includePatterns.empty()) {
|
|
cmd << " include=";
|
|
for (size_t i = 0; i < opts.includePatterns.size(); ++i) {
|
|
if (i)
|
|
cmd << ',';
|
|
cmd << opts.includePatterns[i];
|
|
}
|
|
}
|
|
|
|
RunResult result;
|
|
result.exitCode = 0;
|
|
result.durationSeconds = elapsed;
|
|
result.throughputFilesPerSecond = throughput;
|
|
result.command = cmd.str();
|
|
result.dataDir = runRoot;
|
|
result.finalSnapshot = waitResult.lastSnapshot;
|
|
result.drained = waitResult.completed;
|
|
result.timedOut = !waitResult.completed;
|
|
result.keywordHit = waitResult.keywordHit;
|
|
result.hybridHit = waitResult.hybridHit;
|
|
result.topologyValidated = run.requireTopologyFresh;
|
|
result.probeToken = probeToken;
|
|
result.probeExpectedPath = probeExpectedPath;
|
|
result.timeToKeywordHitMs = nsToMs(waitResult.keywordHitAt);
|
|
result.timeToHybridHitMs = nsToMs(waitResult.hybridHitAt);
|
|
result.timeToDrainedMs = nsToMs(waitResult.drainedAt);
|
|
result.timeToSearchableMs = nsToMs(waitResult.searchableAt);
|
|
result.addDocumentsAdded = addResult.value().documentsAdded;
|
|
result.addMessage = addResult.value().message;
|
|
result.embedQueued = bus.embedQueued() - baseEmbedQueued;
|
|
result.embedConsumed = bus.embedConsumed() - baseEmbedConsumed;
|
|
result.embedDropped = bus.embedDropped() - baseEmbedDropped;
|
|
result.embedPreparedDocsQueued = bus.embedPreparedDocsQueued() - baseEmbedPreparedDocsQueued;
|
|
result.embedPreparedChunksQueued =
|
|
bus.embedPreparedChunksQueued() - baseEmbedPreparedChunksQueued;
|
|
result.embedHashOnlyDocsQueued = bus.embedHashOnlyDocsQueued() - baseEmbedHashOnlyDocsQueued;
|
|
result.postQueued = bus.postQueued() - basePostQueued;
|
|
result.postConsumed = bus.postConsumed() - basePostConsumed;
|
|
result.postDropped = bus.postDropped() - basePostDropped;
|
|
result.kgQueued = bus.kgQueued() - baseKgQueued;
|
|
result.kgConsumed = bus.kgConsumed() - baseKgConsumed;
|
|
result.kgDropped = bus.kgDropped() - baseKgDropped;
|
|
result.entityQueued = bus.entityQueued() - baseEntityQueued;
|
|
result.entityConsumed = bus.entityConsumed() - baseEntityConsumed;
|
|
result.entityDropped = bus.entityDropped() - baseEntityDropped;
|
|
result.titleQueued = bus.titleQueued() - baseTitleQueued;
|
|
result.titleConsumed = bus.titleConsumed() - baseTitleConsumed;
|
|
result.titleDropped = bus.titleDropped() - baseTitleDropped;
|
|
|
|
const bool isSimeonFullPipeline = run.enableEmbeddings && !useMockEmbeddings && useSimeon;
|
|
if (isSimeonFullPipeline && result.titleQueued == 0 && result.titleConsumed == 0) {
|
|
spdlog::error(
|
|
"[bench] Simeon full-pipeline run '{}' has titleQueued==0 and titleConsumed==0 — "
|
|
"title extractor was not wired. This benchmark is misconfigured.",
|
|
run.label);
|
|
result.exitCode = 4;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void appendMetrics(const fs::path& metricsPath, const RunConfig& run, const RunResult& result,
|
|
size_t datasetCount, int iteration) {
|
|
std::ofstream out(metricsPath, std::ios::app);
|
|
if (!out) {
|
|
throw std::runtime_error("Failed to open metrics output: " + metricsPath.string());
|
|
}
|
|
|
|
const auto indexed = static_cast<std::uint64_t>(result.finalSnapshot.documentsTotal);
|
|
const auto processed = static_cast<std::uint64_t>(datasetCount);
|
|
const auto skipped =
|
|
datasetCount > indexed ? static_cast<std::uint64_t>(datasetCount - indexed) : 0;
|
|
const auto failed = static_cast<std::uint64_t>(0);
|
|
|
|
const double throughput = result.throughputFilesPerSecond;
|
|
|
|
json record{
|
|
{"timestamp", isoTimestamp()},
|
|
{"label", run.label},
|
|
{"iteration", iteration},
|
|
{"workers", run.workers},
|
|
{"embedding_model", run.enableEmbeddings ? run.embeddingModel : std::string{}},
|
|
{"require_topology_fresh", run.requireTopologyFresh},
|
|
{"embedding_backend", run.enableEmbeddings ? ((run.embeddingModel == "simeon-default" ||
|
|
run.embeddingModel == "simeon")
|
|
? "simeon"
|
|
: "onnxruntime")
|
|
: "disabled"},
|
|
{"simeon_recipe", run.enableEmbeddings && (run.embeddingModel == "simeon-default" ||
|
|
run.embeddingModel == "simeon")
|
|
? yams::vector::simeonRecipeLabel()
|
|
: std::string{}},
|
|
{"exit_code", result.exitCode},
|
|
{"duration_seconds", result.durationSeconds},
|
|
{"dataset_files", datasetCount},
|
|
{"files_indexed", indexed},
|
|
{"files_processed", processed},
|
|
{"files_skipped", skipped},
|
|
{"files_failed", failed},
|
|
{"throughput_files_per_second", throughput},
|
|
{"time_to_keyword_hit_ms", result.timeToKeywordHitMs},
|
|
{"time_to_hybrid_hit_ms", result.timeToHybridHitMs},
|
|
{"time_to_drained_ms", result.timeToDrainedMs},
|
|
{"time_to_searchable_ms", result.timeToSearchableMs},
|
|
{"keyword_hit", result.keywordHit},
|
|
{"hybrid_hit", result.hybridHit},
|
|
{"probe_token", result.probeToken},
|
|
{"probe_expected_path", result.probeExpectedPath},
|
|
{"timed_out", result.timedOut},
|
|
{"add_documents_added", result.addDocumentsAdded},
|
|
{"add_message", result.addMessage},
|
|
{"documents_total", result.finalSnapshot.documentsTotal},
|
|
{"documents_indexed", result.finalSnapshot.documentsIndexed},
|
|
{"worker_queued", result.finalSnapshot.workerQueued},
|
|
{"worker_active", result.finalSnapshot.workerActive},
|
|
{"post_ingest_queued", result.finalSnapshot.postIngestQueued},
|
|
{"post_ingest_inflight", result.finalSnapshot.postIngestInflight},
|
|
{"post_ingest_processed", result.finalSnapshot.postIngestProcessed},
|
|
{"post_ingest_failed", result.finalSnapshot.postIngestFailed},
|
|
{"pipeline_counters",
|
|
{{"embed",
|
|
{{"queued", result.embedQueued},
|
|
{"consumed", result.embedConsumed},
|
|
{"dropped", result.embedDropped},
|
|
{"prepared_docs_queued", result.embedPreparedDocsQueued},
|
|
{"prepared_chunks_queued", result.embedPreparedChunksQueued},
|
|
{"hash_only_docs_queued", result.embedHashOnlyDocsQueued}}},
|
|
{"post",
|
|
{{"queued", result.postQueued},
|
|
{"consumed", result.postConsumed},
|
|
{"dropped", result.postDropped}}},
|
|
{"kg",
|
|
{{"queued", result.kgQueued},
|
|
{"consumed", result.kgConsumed},
|
|
{"dropped", result.kgDropped}}},
|
|
{"symbol",
|
|
{{"queued", result.symbolQueued},
|
|
{"consumed", result.symbolConsumed},
|
|
{"dropped", result.symbolDropped}}},
|
|
{"entity",
|
|
{{"queued", result.entityQueued},
|
|
{"consumed", result.entityConsumed},
|
|
{"dropped", result.entityDropped}}},
|
|
{"title",
|
|
{{"queued", result.titleQueued},
|
|
{"consumed", result.titleConsumed},
|
|
{"dropped", result.titleDropped}}}}},
|
|
{"topology_validated", result.topologyValidated},
|
|
{"topology_artifacts_fresh", result.finalSnapshot.topologyArtifactsFresh},
|
|
{"topology_rebuild_running", result.finalSnapshot.topologyRebuildRunning},
|
|
{"topology_dirty_documents", result.finalSnapshot.topologyDirtyDocuments},
|
|
{"simeon_lexical_configured", result.finalSnapshot.simeonLexicalConfigured},
|
|
{"simeon_lexical_ready", result.finalSnapshot.simeonLexicalReady},
|
|
{"embed_queued", result.finalSnapshot.embedQueued},
|
|
{"embed_inflight", result.finalSnapshot.embedInflight},
|
|
{"drained", result.drained},
|
|
{"command", result.command},
|
|
{"data_dir", result.dataDir.string()},
|
|
};
|
|
|
|
out << record.dump() << '\n';
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::unordered_map<std::string, double> loadBaselineThroughput(const fs::path& metricsPath) {
|
|
std::unordered_map<std::string, std::pair<double, int>> accum;
|
|
if (!fs::exists(metricsPath))
|
|
return {};
|
|
|
|
std::ifstream in(metricsPath);
|
|
std::string line;
|
|
while (std::getline(in, line)) {
|
|
if (line.empty())
|
|
continue;
|
|
json record = json::parse(line, nullptr, false);
|
|
if (!record.is_object())
|
|
continue;
|
|
if (!record.contains("label") || !record.contains("throughput_files_per_second"))
|
|
continue;
|
|
double throughput = record.value("throughput_files_per_second", 0.0);
|
|
if (throughput <= 0.0)
|
|
continue;
|
|
std::string label = record.value("label", std::string{});
|
|
if (label.empty())
|
|
continue;
|
|
auto& slot = accum[label];
|
|
slot.first += throughput;
|
|
slot.second += 1;
|
|
}
|
|
|
|
std::unordered_map<std::string, double> averages;
|
|
for (auto& [label, data] : accum) {
|
|
if (data.second > 0)
|
|
averages[label] = data.first / static_cast<double>(data.second);
|
|
}
|
|
return averages;
|
|
}
|
|
|
|
int main(int argc, char** argv) {
|
|
spdlog::set_level(spdlog::level::warn);
|
|
fs::path configPath;
|
|
for (int i = 1; i < argc; ++i) {
|
|
std::string arg(argv[i]);
|
|
if ((arg == "--config" || arg == "-c") && i + 1 < argc) {
|
|
configPath = fs::path(argv[++i]);
|
|
} else if (arg == "--help" || arg == "-h") {
|
|
std::cout << "Usage: " << argv[0] << " --config <path>\n";
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
if (configPath.empty()) {
|
|
std::cerr << "error: --config <path> is required\n";
|
|
return 2;
|
|
}
|
|
|
|
try {
|
|
BenchConfig cfg = loadConfig(configPath);
|
|
const size_t datasetCount = countFiles(cfg.datasetPath);
|
|
std::cout << "[bench] Dataset: " << cfg.datasetPath << " files=" << datasetCount
|
|
<< std::endl;
|
|
|
|
ensureMetricsPath(cfg.metricsPath);
|
|
auto baseline = loadBaselineThroughput(cfg.metricsPath);
|
|
std::unordered_map<std::string, std::vector<double>> newThroughputs;
|
|
|
|
for (const auto& run : cfg.runs) {
|
|
for (int iteration = 0; iteration < run.repeat; ++iteration) {
|
|
RunResult result = executeRun(cfg, run, datasetCount, iteration);
|
|
appendMetrics(cfg.metricsPath, run, result, datasetCount, iteration);
|
|
newThroughputs[run.label].push_back(result.throughputFilesPerSecond);
|
|
if (cfg.postRunCleanup) {
|
|
deleteTree(result.dataDir);
|
|
}
|
|
if (result.exitCode != 0) {
|
|
return result.exitCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool regressionDetected = false;
|
|
for (const auto& [label, samples] : newThroughputs) {
|
|
if (samples.empty())
|
|
continue;
|
|
double newAvg = std::accumulate(samples.begin(), samples.end(), 0.0) /
|
|
static_cast<double>(samples.size());
|
|
auto it = baseline.find(label);
|
|
if (it != baseline.end() && it->second > 0.0) {
|
|
double threshold = it->second * 0.9;
|
|
if (newAvg < threshold) {
|
|
std::cerr << "[bench] throughput regression detected for '" << label
|
|
<< "': baseline=" << it->second << " new=" << newAvg
|
|
<< " (threshold=" << threshold << ")" << std::endl;
|
|
regressionDetected = true;
|
|
}
|
|
}
|
|
}
|
|
if (regressionDetected)
|
|
return 3;
|
|
} catch (const std::exception& ex) {
|
|
std::cerr << "error: " << ex.what() << "\n";
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|