// bench_runner.cpp — frame-by-frame benchmark harness for data_table::render(). // Issue 0133: perf gate for 10M-row refactor. // // Design: Runner::tick() is called once per fn::run_app frame. // Each frame it renders the table for the current scenario and measures the // frame time (wall clock from tick() entry to exit). After duration_s seconds // it finalizes the scenario, moves to the next one, and eventually sets done_. // // This avoids any nested-frame or out-of-context ImGui calls — tick() runs // inside a valid ImGui frame, after NewFrame() and before Render(). #include "bench_runner.h" #include "data_table/data_table.h" #include "core/logger.h" #include #include #include #include #include #include #include #include #include #ifdef __linux__ # include #endif #ifdef _WIN32 # include # include #endif namespace bench { // --------------------------------------------------------------------------- // System metrics // --------------------------------------------------------------------------- double measure_rss_mb() { #ifdef __linux__ FILE* f = fopen("/proc/self/status", "r"); if (!f) return 0.0; char line[256]; while (fgets(line, sizeof(line), f)) { long kb = 0; if (sscanf(line, "VmRSS: %ld kB", &kb) == 1) { fclose(f); return static_cast(kb) / 1024.0; } } fclose(f); return 0.0; #elif defined(_WIN32) PROCESS_MEMORY_COUNTERS pmc; if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) return static_cast(pmc.WorkingSetSize) / (1024.0 * 1024.0); return 0.0; #else return 0.0; #endif } double measure_cpu_pct(double wall_s, double user_s, double sys_s) { if (wall_s < 1e-9) return 0.0; return 100.0 * (user_s + sys_s) / wall_s; } static void get_rusage_times(double& user_s, double& sys_s) { #ifdef __linux__ struct rusage ru; getrusage(RUSAGE_SELF, &ru); user_s = ru.ru_utime.tv_sec + ru.ru_utime.tv_usec * 1e-6; sys_s = ru.ru_stime.tv_sec + ru.ru_stime.tv_usec * 1e-6; #else user_s = 0.0; sys_s = 0.0; #endif } // --------------------------------------------------------------------------- // ScenarioState — holds TableInput + data_table::State for one scenario. // --------------------------------------------------------------------------- struct Runner::ScenarioState { data_table::TableInput ti; data_table::State st; }; // --------------------------------------------------------------------------- // Dataset generation — deterministic, 20 columns, 5 int / 5 float / 5 str / 5 ts. // --------------------------------------------------------------------------- Runner::Runner(const Config& cfg) : cfg_(cfg) { seed_dataset(); } void Runner::seed_dataset() { const long long N = cfg_.rows; const int C = cfg_.cols; backing_.clear(); backing_.reserve(static_cast(N) * static_cast(C)); static const char* statuses[] = {"ok", "error", "running", "pending", "blocked"}; static const char* categories[] = {"alpha", "beta", "gamma", "delta", "epsilon"}; static const char* tags_a[] = {"foo", "bar", "baz", "qux", "quux"}; static const char* labels_a[] = {"low", "medium", "high", "critical", "unknown"}; char buf[64]; for (long long i = 0; i < N; ++i) { // 0: id (int) snprintf(buf, sizeof(buf), "%lld", i + 1); backing_.emplace_back(buf); // 1: count (int 0..9999) snprintf(buf, sizeof(buf), "%lld", (i * 7 + 3) % 10000); backing_.emplace_back(buf); // 2: score (int 0..1000) snprintf(buf, sizeof(buf), "%lld", (i * 131) % 1001); backing_.emplace_back(buf); // 3: rank (int 0..499) snprintf(buf, sizeof(buf), "%lld", (i * 17) % 500); backing_.emplace_back(buf); // 4: flags (int 0..15) snprintf(buf, sizeof(buf), "%lld", i & 0xF); backing_.emplace_back(buf); // 5: ratio (float 0..1) snprintf(buf, sizeof(buf), "%.6f", static_cast(i % 10000) / 10000.0); backing_.emplace_back(buf); // 6: pct (float 0..100) snprintf(buf, sizeof(buf), "%.4f", static_cast((i * 37) % 10001) / 100.0); backing_.emplace_back(buf); // 7: value (float 0..9999, for ColorRule p95 scenario) snprintf(buf, sizeof(buf), "%.2f", static_cast((i * 71) % 10000)); backing_.emplace_back(buf); // 8: rate (float) snprintf(buf, sizeof(buf), "%.4f", static_cast((i * 13) % 5000) / 500.0); backing_.emplace_back(buf); // 9: weight (float) snprintf(buf, sizeof(buf), "%.3f", static_cast((i * 29) % 1000) / 100.0); backing_.emplace_back(buf); // 10: name (string; "item_foo_N" every 7th row for filter_like) if (i % 7 == 0) snprintf(buf, sizeof(buf), "item_foo_%lld", i); else snprintf(buf, sizeof(buf), "item_%lld", i); backing_.emplace_back(buf); // 11: status backing_.emplace_back(statuses[i % 5]); // 12: category backing_.emplace_back(categories[(i * 3) % 5]); // 13: tag backing_.emplace_back(tags_a[(i * 5) % 5]); // 14: label backing_.emplace_back(labels_a[(i * 11) % 5]); // 15: created_at (int as string) snprintf(buf, sizeof(buf), "%lld", 1700000000LL + i * 60); backing_.emplace_back(buf); // 16: updated_at snprintf(buf, sizeof(buf), "%lld", 1700000000LL + i * 60 + 30); backing_.emplace_back(buf); // 17: closed_at snprintf(buf, sizeof(buf), "%lld", 1700000000LL + i * 60 + (i % 3 == 0 ? 120 : 0)); backing_.emplace_back(buf); // 18: due_at snprintf(buf, sizeof(buf), "%lld", 1700000000LL + (i + 1000) * 3600); backing_.emplace_back(buf); // 19: ts snprintf(buf, sizeof(buf), "%lld", 1700000000LL + i); backing_.emplace_back(buf); } ptrs_.clear(); ptrs_.reserve(backing_.size()); for (const auto& s : backing_) ptrs_.push_back(s.c_str()); } // --------------------------------------------------------------------------- // begin_scenario — sets up TableInput + State for a scenario. // --------------------------------------------------------------------------- void Runner::begin_scenario(Scenario s) { delete sc_state_; sc_state_ = new ScenarioState(); // TableInput. sc_state_->ti.name = "bench_data"; sc_state_->ti.headers = { "id", "count", "score", "rank", "flags", "ratio", "pct", "value", "rate", "weight", "name", "status", "category", "tag", "label", "created_at", "updated_at", "closed_at", "due_at", "ts" }; using CT = data_table::ColumnType; sc_state_->ti.types = { CT::Int, CT::Int, CT::Int, CT::Int, CT::Int, CT::Float, CT::Float, CT::Float, CT::Float, CT::Float, CT::String, CT::String, CT::String, CT::String, CT::String, CT::Int, CT::Int, CT::Int, CT::Int, CT::Int }; sc_state_->ti.cells = ptrs_.data(); sc_state_->ti.rows = static_cast(cfg_.rows); sc_state_->ti.cols = cfg_.cols; // State. sc_state_->st = data_table::State{}; sc_state_->st.ensure_stage0(); switch (s) { case Scenario::FilterLike: { data_table::Filter f; f.col = 10; // name column f.op = data_table::Op::Contains; f.value = "foo"; sc_state_->st.raw().filters.push_back(f); break; } case Scenario::SortNumeric: { data_table::SortClause sc; sc.col = "score"; sc.desc = true; sc_state_->st.raw().sorts.push_back(sc); break; } case Scenario::ColorRule: { data_table::ColorRule cr; cr.col = 7; // value column cr.kind = data_table::ColorRuleKind::NumericRange; cr.range_min = 0.0; cr.range_max = 9999.0; cr.range_alpha = 0.3f; cr.range_stops.push_back({0.0f, "#22c55e"}); cr.range_stops.push_back({1.0f, "#ef4444"}); sc_state_->st.color_rules.push_back(cr); break; } case Scenario::LinearScroll: break; } // Timing init. frame_fps_.clear(); frame_fps_.reserve(4096); scroll_row_ = 0; scenario_wall_start_ = Clock::now(); last_frame_start_ = Clock::now(); get_rusage_times(user_start_, sys_start_); fprintf(stderr, "[bench] scenario: %s (rows=%lld duration=%.0fs)\n", scenario_name(s), cfg_.rows, cfg_.duration_s); fflush(stderr); } // --------------------------------------------------------------------------- // finish_scenario — collects stats from frame_fps_ and stores result. // --------------------------------------------------------------------------- void Runner::finish_scenario() { Scenario s = kScenarios[current_scenario_]; double user1 = 0.0, sys1 = 0.0; get_rusage_times(user1, sys1); double wall_s = std::chrono::duration( Clock::now() - scenario_wall_start_).count(); ScenarioResult res; res.scenario = s; res.duration_s = wall_s; res.mem_rss_mb = measure_rss_mb(); res.cpu_pct = measure_cpu_pct(wall_s, user1 - user_start_, sys1 - sys_start_); if (!frame_fps_.empty()) { std::sort(frame_fps_.begin(), frame_fps_.end()); size_t n = frame_fps_.size(); res.fps_p50 = frame_fps_[n / 2]; size_t p1i = n * 1 / 100; res.fps_p1 = frame_fps_[p1i]; } res.pass = (res.fps_p1 >= cfg_.fps_threshold); fn_log::log_info("bench: scenario=%s frames=%zu fps_p50=%.1f fps_p1=%.1f " "mem=%.1fMB cpu=%.1f%% %s", scenario_name(s), frame_fps_.size(), res.fps_p50, res.fps_p1, res.mem_rss_mb, res.cpu_pct, res.pass ? "PASS" : "FAIL"); fprintf(stderr, "[bench] %s: fps_p50=%.1f fps_p1=%.1f mem=%.1fMB %s\n", scenario_name(s), res.fps_p50, res.fps_p1, res.mem_rss_mb, res.pass ? "PASS" : "FAIL"); fflush(stderr); results_.push_back(res); delete sc_state_; sc_state_ = nullptr; } // --------------------------------------------------------------------------- // render_table_frame — render one frame for the current scenario. // Called inside an active ImGui frame (after NewFrame, before Render). // --------------------------------------------------------------------------- void Runner::render_table_frame(Scenario s) { if (!sc_state_) return; ImGui::SetNextWindowSize(ImVec2(1920, 1080), ImGuiCond_Always); ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always); ImGui::Begin("##bench_table", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize); if (s == Scenario::LinearScroll && sc_state_->ti.rows > 0) { float row_h = ImGui::GetTextLineHeightWithSpacing(); ImGui::SetScrollY(static_cast(scroll_row_) * row_h); scroll_row_ = (scroll_row_ + 1) % sc_state_->ti.rows; } data_table::render("##bench_tbl", {sc_state_->ti}, sc_state_->st, nullptr, false); ImGui::End(); } // --------------------------------------------------------------------------- // tick — one frame of the benchmark state machine. // --------------------------------------------------------------------------- bool Runner::tick() { if (done_) return true; Scenario s = kScenarios[current_scenario_]; auto now = Clock::now(); // Start new scenario on first tick or after transition. if (!scenario_started_) { begin_scenario(s); scenario_started_ = true; last_frame_start_ = now; } // Measure frame time from last tick entry. double frame_ms = std::chrono::duration( now - last_frame_start_).count(); last_frame_start_ = now; // Record FPS (skip the very first frame — it includes scenario setup time). if (frame_ms > 0.01 && results_.size() == static_cast(current_scenario_)) { frame_fps_.push_back(1000.0 / frame_ms); } // Render table for this frame. render_table_frame(s); // Check if scenario duration has elapsed. double elapsed = std::chrono::duration( Clock::now() - scenario_wall_start_).count(); if (elapsed >= cfg_.duration_s) { finish_scenario(); current_scenario_++; scenario_started_ = false; if (current_scenario_ >= kNumScenarios) { done_ = true; return true; } } return false; } // --------------------------------------------------------------------------- // Accessors // --------------------------------------------------------------------------- bool Runner::all_passed() const { if (results_.empty()) return false; for (const auto& r : results_) if (!r.pass) return false; return true; } // --------------------------------------------------------------------------- // JSON output // --------------------------------------------------------------------------- void Runner::print_json() const { printf("{\n"); printf(" \"rows\": %lld,\n", cfg_.rows); printf(" \"fps_threshold\": %.1f,\n", cfg_.fps_threshold); printf(" \"scenarios\": [\n"); for (size_t i = 0; i < results_.size(); ++i) { const auto& r = results_[i]; printf(" {\n"); printf(" \"scenario\": \"%s\",\n", scenario_name(r.scenario)); printf(" \"fps_p50\": %.2f,\n", r.fps_p50); printf(" \"fps_p1\": %.2f,\n", r.fps_p1); printf(" \"mem_rss_mb\": %.1f,\n", r.mem_rss_mb); printf(" \"cpu_pct\": %.1f,\n", r.cpu_pct); printf(" \"duration_s\": %.2f,\n", r.duration_s); printf(" \"pass\": %s\n", r.pass ? "true" : "false"); printf(" }%s\n", (i + 1 < results_.size()) ? "," : ""); } printf(" ],\n"); printf(" \"overall_pass\": %s\n", all_passed() ? "true" : "false"); printf("}\n"); } // --------------------------------------------------------------------------- // SQLite persistence // --------------------------------------------------------------------------- static const char* kCreateSQL = "CREATE TABLE IF NOT EXISTS bench_runs (" " id TEXT PRIMARY KEY," " started_at INTEGER NOT NULL," " rows INTEGER NOT NULL," " scenario TEXT NOT NULL," " fps_p50 REAL NOT NULL," " fps_p1 REAL NOT NULL," " mem_rss_mb REAL NOT NULL," " cpu_pct REAL NOT NULL," " duration_s REAL NOT NULL," " commit_sha TEXT NOT NULL DEFAULT ''," " summary_json TEXT NOT NULL DEFAULT '{}'" ");"; void Runner::persist(const std::string& db_path) const { if (db_path.empty()) return; sqlite3* db = nullptr; if (sqlite3_open(db_path.c_str(), &db) != SQLITE_OK) { fprintf(stderr, "[bench] sqlite3_open(%s) failed: %s\n", db_path.c_str(), sqlite3_errmsg(db)); if (db) sqlite3_close(db); return; } sqlite3_exec(db, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr); sqlite3_exec(db, kCreateSQL, nullptr, nullptr, nullptr); auto now_ts = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); const char* kInsert = "INSERT OR REPLACE INTO bench_runs " "(id, started_at, rows, scenario, fps_p50, fps_p1, mem_rss_mb, cpu_pct, " " duration_s, commit_sha, summary_json) " "VALUES (?,?,?,?,?,?,?,?,?,?,?);"; for (const auto& r : results_) { char id_buf[128]; snprintf(id_buf, sizeof(id_buf), "%lld_%s", now_ts, scenario_name(r.scenario)); char json_buf[256]; snprintf(json_buf, sizeof(json_buf), "{\"fps_p50\":%.2f,\"fps_p1\":%.2f,\"mem_rss_mb\":%.1f," "\"cpu_pct\":%.1f,\"pass\":%s}", r.fps_p50, r.fps_p1, r.mem_rss_mb, r.cpu_pct, r.pass ? "true" : "false"); sqlite3_stmt* stmt = nullptr; sqlite3_prepare_v2(db, kInsert, -1, &stmt, nullptr); sqlite3_bind_text (stmt, 1, id_buf, -1, SQLITE_TRANSIENT); sqlite3_bind_int64 (stmt, 2, now_ts); sqlite3_bind_int64 (stmt, 3, cfg_.rows); sqlite3_bind_text (stmt, 4, scenario_name(r.scenario), -1, SQLITE_STATIC); sqlite3_bind_double(stmt, 5, r.fps_p50); sqlite3_bind_double(stmt, 6, r.fps_p1); sqlite3_bind_double(stmt, 7, r.mem_rss_mb); sqlite3_bind_double(stmt, 8, r.cpu_pct); sqlite3_bind_double(stmt, 9, r.duration_s); sqlite3_bind_text (stmt, 10, cfg_.commit_sha.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text (stmt, 11, json_buf, -1, SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_finalize(stmt); } sqlite3_close(db); } } // namespace bench