feat: initial scaffold data_table_bench (issue 0133)

This commit is contained in:
agent_A
2026-05-22 23:37:50 +02:00
commit 09ccc6848b
7 changed files with 923 additions and 0 deletions
+487
View File
@@ -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 <imgui.h>
#include <sqlite3.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <string>
#include <vector>
#ifdef __linux__
# include <sys/resource.h>
#endif
#ifdef _WIN32
# include <windows.h>
# include <psapi.h>
#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<double>(kb) / 1024.0;
}
}
fclose(f);
return 0.0;
#elif defined(_WIN32)
PROCESS_MEMORY_COUNTERS pmc;
if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc)))
return static_cast<double>(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<size_t>(N) * static_cast<size_t>(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<double>(i % 10000) / 10000.0);
backing_.emplace_back(buf);
// 6: pct (float 0..100)
snprintf(buf, sizeof(buf), "%.4f", static_cast<double>((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<double>((i * 71) % 10000));
backing_.emplace_back(buf);
// 8: rate (float)
snprintf(buf, sizeof(buf), "%.4f", static_cast<double>((i * 13) % 5000) / 500.0);
backing_.emplace_back(buf);
// 9: weight (float)
snprintf(buf, sizeof(buf), "%.3f", static_cast<double>((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<int>(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<double>(
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<float>(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<double, std::milli>(
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<size_t>(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<double>(
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<long long>(
std::chrono::duration_cast<std::chrono::seconds>(
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