From 09ccc6848b484d2788b3715eb653a31c77399f0b Mon Sep 17 00:00:00 2001 From: agent_A Date: Fri, 22 May 2026 23:37:50 +0200 Subject: [PATCH] feat: initial scaffold data_table_bench (issue 0133) --- CMakeLists.txt | 18 ++ app.md | 107 +++++++++ bench_runner.cpp | 487 ++++++++++++++++++++++++++++++++++++++++ bench_runner.h | 127 +++++++++++ data_table_bench.log | 6 + main.cpp | 163 ++++++++++++++ migrations/001_init.sql | 15 ++ 7 files changed, 923 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 app.md create mode 100644 bench_runner.cpp create mode 100644 bench_runner.h create mode 100644 data_table_bench.log create mode 100644 main.cpp create mode 100644 migrations/001_init.sql diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3f11e7f --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,18 @@ +add_imgui_app(data_table_bench + main.cpp + bench_runner.cpp +) +target_include_directories(data_table_bench PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# fn_module_data_table: data_table::render(), TQL engine, Lua. +if(TARGET fn_module_data_table) + target_link_libraries(data_table_bench PRIVATE fn_module_data_table) +endif() + +# SQLite3 for operations.db persistence. +find_package(SQLite3 REQUIRED) +target_link_libraries(data_table_bench PRIVATE SQLite::SQLite3) + +if(WIN32) + set_target_properties(data_table_bench PROPERTIES WIN32_EXECUTABLE FALSE) +endif() diff --git a/app.md b/app.md new file mode 100644 index 0000000..3ac99e0 --- /dev/null +++ b/app.md @@ -0,0 +1,107 @@ +--- +name: data_table_bench +lang: cpp +domain: tools +version: 0.1.0 +description: "Headless 10M-row performance gate for data_table::render() (issue 0133). Mide fps_p50/fps_p1/mem/cpu en 4 escenarios: scroll lineal, filter LIKE, sort numerico y color rule." +tags: [imgui, bench, headless, data-table, perf] +uses_functions: + - data_table_cpp_viz +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "apps/data_table_bench" +repo_url: "" +icon: + phosphor: "gauge" + accent: "#22d3ee" +e2e_checks: + - id: build + cmd: "cd cpp/build/linux && cmake --build . --target data_table_bench -j 4" + timeout_s: 300 + - id: smoke_100k + cmd: "cd apps/data_table_bench && xvfb-run -a -s \"-screen 0 1920x1080x24\" env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe ../../cpp/build/linux/apps/data_table_bench/data_table_bench --rows 100000 --duration 5 --no-db" + timeout_s: 120 + expect_stdout_contains: "\"overall_pass\":" + severity: warning +--- + +# data_table_bench + +Headless benchmark app para el modulo `data_table` (issue 0133). Gate de performance entre cada cambio del refactor 10M-row. + +## Uso + +```bash +# CI smoke (rapido, 100k filas, 5s por escenario) +cd apps/data_table_bench +xvfb-run -a -s "-screen 0 1920x1080x24" \ + env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ + ../../cpp/build/linux/apps/data_table_bench/data_table_bench \ + --rows 100000 --duration 5 --no-db + +# Full bench (10M filas, 30s por escenario) +xvfb-run -a -s "-screen 0 1920x1080x24" \ + env LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe \ + ../../cpp/build/linux/apps/data_table_bench/data_table_bench \ + --rows 10000000 --duration 30 + +# Con persistencia SQLite +./data_table_bench --rows 10000000 --duration 30 --db operations.db --sha $(git rev-parse HEAD) +``` + +## Output ejemplo + +```json +{ + "rows": 100000, + "fps_threshold": 60.0, + "scenarios": [ + { "scenario": "linear_scroll", "fps_p50": 320.5, "fps_p1": 125.3, "mem_rss_mb": 450.2, "cpu_pct": 85.1, "duration_s": 5.0, "pass": true }, + { "scenario": "filter_like", "fps_p50": 210.1, "fps_p1": 95.2, "mem_rss_mb": 450.5, "cpu_pct": 92.3, "duration_s": 5.0, "pass": true }, + { "scenario": "sort_numeric", "fps_p50": 180.3, "fps_p1": 70.1, "mem_rss_mb": 451.0, "cpu_pct": 88.7, "duration_s": 5.0, "pass": true }, + { "scenario": "color_rule", "fps_p50": 290.7, "fps_p1": 98.4, "mem_rss_mb": 451.2, "cpu_pct": 86.2, "duration_s": 5.0, "pass": true } + ], + "overall_pass": true +} +``` + +## Assertion DoD + +`fps_p1 >= 60` en cada escenario con 10M filas a 30s. + +## Escenarios + +| ID | Patron | Lo que estresan | +|---|---|---| +| `linear_scroll` | Avanza el scroll 1 fila/frame | Clipper virtual scroll en O(1) | +| `filter_like` | Contains("foo") sobre col name | Scan de 10M strings por frame | +| `sort_numeric` | Sort score DESC | Reordenacion del stage pipeline | +| `color_rule` | NumericRange gradient en col value | Pintado condicional de 10M celdas | + +## Build + +```bash +cd cpp/build/linux +cmake --build . --target data_table_bench -j 4 +``` + +## Dataset + +Sintetico y determinista (mismo seed => mismas filas). 20 columnas: +- Cols 0-4: int (id, count, score, rank, flags) +- Cols 5-9: float (ratio, pct, value, rate, weight) +- Cols 10-14: string (name, status, category, tag, label) +- Cols 15-19: timestamp como int string (created_at, updated_at, closed_at, due_at, ts) + +## Gotchas + +- Requiere xvfb + llvmpipe en CI Linux (sin GPU real). Instalar: `sudo apt-get install xvfb libgles2-mesa`. +- El seed de 10M filas puede tardar 10-15s antes de arrancar el GL context. +- `vsync = false` en AppConfig para que el bench no este limitado por el refresh rate del display virtual. +- La ventana se oculta con `glfwHideWindow()` en el primer frame — nunca aparece en pantalla. +- Windows: sin cross-compile habilitado el bench es solo Linux. El DoD del issue 0133 es Linux. + +## Capability growth log + +- v0.1.0 (2026-05-22) — baseline para issue 0133. diff --git a/bench_runner.cpp b/bench_runner.cpp new file mode 100644 index 0000000..70d7288 --- /dev/null +++ b/bench_runner.cpp @@ -0,0 +1,487 @@ +// 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 diff --git a/bench_runner.h b/bench_runner.h new file mode 100644 index 0000000..2543292 --- /dev/null +++ b/bench_runner.h @@ -0,0 +1,127 @@ +#pragma once +// bench_runner — headless benchmark harness for data_table::render(). +// Issue 0133: perf gate for 10M-row refactor. +// +// Architecture: the Runner is driven frame-by-frame from within fn::run_app's +// render callback. Each frame it calls data_table::render() for the current +// scenario and measures the frame time. After duration_s seconds it moves to +// the next scenario. After all 4 scenarios it signals completion. +// +// Usage in render callback: +// static bench::Runner runner(cfg); +// runner.tick(); // called every frame +// if (runner.done()) { ... print + persist + close window ... } + +#include +#include +#include + +namespace bench { + +enum class Scenario { + LinearScroll, // advance row offset each frame + FilterLike, // filter name CONTAINS "foo" + SortNumeric, // sort score col descending + ColorRule, // NumericRange color rule on value col +}; + +inline const char* scenario_name(Scenario s) { + switch (s) { + case Scenario::LinearScroll: return "linear_scroll"; + case Scenario::FilterLike: return "filter_like"; + case Scenario::SortNumeric: return "sort_numeric"; + case Scenario::ColorRule: return "color_rule"; + } + return "unknown"; +} + +struct ScenarioResult { + Scenario scenario = Scenario::LinearScroll; + double fps_p50 = 0.0; + double fps_p1 = 0.0; + double mem_rss_mb = 0.0; + double cpu_pct = 0.0; + double duration_s = 0.0; + bool pass = false; +}; + +struct Config { + long long rows = 10000000; + int cols = 20; + double duration_s = 30.0; + double fps_threshold = 60.0; + std::string db_path = "operations.db"; + std::string commit_sha; + bool verbose = false; +}; + +class Runner { +public: + explicit Runner(const Config& cfg); + + // Called once per render frame. Internally manages scenario transitions. + // Returns true when all scenarios are finished. + bool tick(); + + // Returns true when all scenarios have completed. + bool done() const { return done_; } + + // Print JSON summary to stdout. + void print_json() const; + + // Persist results into bench_runs SQLite table. + void persist(const std::string& db_path) const; + + // Returns false if any scenario failed fps_threshold assertion. + bool all_passed() const; + + const std::vector& results() const { return results_; } + +private: + Config cfg_; + std::vector results_; + bool done_ = false; + + // Backing storage for synthetic dataset (row-major strings). + std::vector backing_; + std::vector ptrs_; + + // Per-scenario timing state. + int current_scenario_ = 0; + bool scenario_started_ = false; + + // Frame timing for current scenario. + using Clock = std::chrono::steady_clock; + Clock::time_point scenario_wall_start_; + Clock::time_point last_frame_start_; + std::vector frame_fps_; + double user_start_ = 0.0; + double sys_start_ = 0.0; + + // Scenario-specific state (rebuilt each scenario). + struct ScenarioState; + ScenarioState* sc_state_ = nullptr; + + int scroll_row_ = 0; + + static constexpr Scenario kScenarios[] = { + Scenario::LinearScroll, + Scenario::FilterLike, + Scenario::SortNumeric, + Scenario::ColorRule, + }; + static constexpr int kNumScenarios = 4; + + void seed_dataset(); + void begin_scenario(Scenario s); + void finish_scenario(); + void render_table_frame(Scenario s); +}; + +// Measure RSS memory in MB (Linux: /proc/self/status). +double measure_rss_mb(); + +// Measure CPU % from RUSAGE delta over wall time. +double measure_cpu_pct(double wall_s, double user_s, double sys_s); + +} // namespace bench diff --git a/data_table_bench.log b/data_table_bench.log new file mode 100644 index 0000000..483b182 --- /dev/null +++ b/data_table_bench.log @@ -0,0 +1,6 @@ +[2026-05-22 23:37:25.548] [INFO] app start: data_table_bench +[2026-05-22 23:37:30.625] [INFO] bench: scenario=linear_scroll frames=512 fps_p50=104.1 fps_p1=84.7 mem=124.8MB cpu=763.8% PASS +[2026-05-22 23:37:35.637] [INFO] bench: scenario=filter_like frames=757 fps_p50=153.2 fps_p1=125.1 mem=124.8MB cpu=1096.1% PASS +[2026-05-22 23:37:40.654] [INFO] bench: scenario=sort_numeric frames=273 fps_p50=54.9 fps_p1=48.6 mem=125.1MB cpu=452.1% FAIL +[2026-05-22 23:37:45.662] [INFO] bench: scenario=color_rule frames=522 fps_p50=105.3 fps_p1=93.0 mem=125.1MB cpu=787.5% PASS +[2026-05-22 23:37:45.667] [INFO] app exit diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..43d7a60 --- /dev/null +++ b/main.cpp @@ -0,0 +1,163 @@ +// data_table_bench — headless performance gate for data_table::render(). +// Issue 0133: 10M-row bench for the upcoming data_table refactor. +// +// Usage: +// ./data_table_bench --rows 10000000 --duration 30 +// ./data_table_bench --rows 100000 --duration 5 # CI smoke (fast) +// ./data_table_bench --rows 1000000 --duration 10 --no-db +// +// Output: JSON to stdout. +// Exit 0 if all scenarios pass (fps_p1 >= threshold), 1 otherwise. +// +// Headless strategy: +// fn::run_app creates the GL context (needed for ImGui clipper to work +// on 10M-row virtual scroll). We hide the GLFW window via glfwHideWindow() +// on the first frame. The Runner::tick() is called each frame to drive the +// benchmark state machine across all 4 scenarios. When tick() returns true +// (all done) we close the window and fn::run_app exits. + +#include "app_base.h" +#include "core/logger.h" +#include "bench_runner.h" + +#include +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +static void print_usage() { + fprintf(stderr, + "Usage: data_table_bench [options]\n" + " --rows N Number of rows (default: 10000000)\n" + " --duration S Seconds per scenario (default: 30)\n" + " --threshold F fps_p1 pass threshold (default: 60.0)\n" + " --db PATH Persist results to SQLite (default: operations.db)\n" + " --no-db Skip persistence\n" + " --sha SHA Git commit SHA to tag run\n" + " --verbose Extra logging\n" + ); +} + +struct Args { + long long rows = 10000000; + double duration_s = 30.0; + double threshold = 60.0; + std::string db_path = "operations.db"; + std::string commit_sha; + bool verbose = false; +}; + +static Args parse_args(int argc, char** argv) { + Args a; + for (int i = 1; i < argc; ++i) { + if (strcmp(argv[i], "--rows") == 0 && i + 1 < argc) + a.rows = atoll(argv[++i]); + else if (strcmp(argv[i], "--duration") == 0 && i + 1 < argc) + a.duration_s = atof(argv[++i]); + else if (strcmp(argv[i], "--threshold") == 0 && i + 1 < argc) + a.threshold = atof(argv[++i]); + else if (strcmp(argv[i], "--db") == 0 && i + 1 < argc) + a.db_path = argv[++i]; + else if (strcmp(argv[i], "--no-db") == 0) + a.db_path = ""; + else if (strcmp(argv[i], "--sha") == 0 && i + 1 < argc) + a.commit_sha = argv[++i]; + else if (strcmp(argv[i], "--verbose") == 0) + a.verbose = true; + else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { + print_usage(); + exit(0); + } + } + return a; +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +int main(int argc, char** argv) { + Args args = parse_args(argc, argv); + + fprintf(stderr, "[bench] rows=%lld duration=%.0fs threshold=%.0f db=%s\n", + args.rows, args.duration_s, args.threshold, + args.db_path.empty() ? "(none)" : args.db_path.c_str()); + + bench::Config cfg; + cfg.rows = args.rows; + cfg.duration_s = args.duration_s; + cfg.fps_threshold = args.threshold; + cfg.db_path = args.db_path; + cfg.commit_sha = args.commit_sha; + cfg.verbose = args.verbose; + + // Seed dataset before opening GL context — can take several seconds for 10M. + fprintf(stderr, "[bench] seeding %lld x %d dataset... ", cfg.rows, cfg.cols); + fflush(stderr); + bench::Runner runner(cfg); + fprintf(stderr, "done\n"); + fflush(stderr); + + // fn::run_app config — headless visual setup. + fn::AppConfig app_cfg; + app_cfg.title = "data_table_bench"; + app_cfg.width = 1920; + app_cfg.height = 1080; + app_cfg.vsync = false; // uncapped FPS for meaningful bench + app_cfg.viewports = false; // no floating panels + app_cfg.auto_dockspace = false; // no dockspace overhead + app_cfg.auto_layouts = false; // no layout DB + + app_cfg.about.name = "data_table_bench"; + app_cfg.about.version = "0.1.0"; + app_cfg.about.description = "Headless 10M-row bench for data_table (issue 0133)"; + app_cfg.log = {"data_table_bench.log", 1}; + app_cfg.header_badge.enabled = false; // headless — no badge + + static GLFWwindow* g_window = nullptr; + bool window_hidden = false; + + int rc = fn::run_app(app_cfg, [&]() { + // First frame: grab window handle and hide. + if (!g_window) { + ImGuiViewport* vp = ImGui::GetMainViewport(); + if (vp && vp->PlatformHandle) { + g_window = (GLFWwindow*)vp->PlatformHandle; + glfwHideWindow(g_window); + window_hidden = true; + } + } + + // tick() drives the benchmark state machine, renders the table, + // and returns true when all 4 scenarios are complete. + bool finished = runner.tick(); + if (finished && g_window) { + glfwSetWindowShouldClose(g_window, GLFW_TRUE); + } + }); + + if (rc != 0) { + fprintf(stderr, "[bench] run_app error: %d\n", rc); + return 1; + } + + // Print JSON to stdout. + runner.print_json(); + + // Persist to SQLite. + if (!args.db_path.empty()) { + runner.persist(args.db_path); + fprintf(stderr, "[bench] results persisted to %s\n", args.db_path.c_str()); + } + + bool passed = runner.all_passed(); + fprintf(stderr, "[bench] overall: %s\n", passed ? "PASS" : "FAIL"); + return passed ? 0 : 1; +} diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..58d0b8d --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,15 @@ +-- data_table_bench: schema inicial +-- Tabla bench_runs: persiste resultados de cada ejecucion del benchmark. +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 '{}' +);