Files
clice/benchmarks/index_benchmark.cpp
ykiko e1dde9ee92 fix(index): address pre-PR review findings
- Fix use-after-partial-move and division-by-zero in benchmark
- Use std::exchange in ContextBitmap move operations
- Add clarifying comments on operator== mixed mode and hash() sorting
- Add tests: compilation-context merge cache hit, hash determinism,
  any_not_in empty inputs, remove edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:49:39 +08:00

665 lines
23 KiB
C++

/// Benchmark for TUIndex build, MergedIndex merge, and query performance.
///
/// Usage:
/// index_benchmark [OPTIONS] <compile_commands.json>
///
/// Example:
/// ./build/RelWithDebInfo/bin/index_benchmark \
/// /home/ykiko/C++/clice/.llvm/build-debug/compile_commands.json
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <mutex>
#include <numeric>
#include <print>
#include <sstream>
#include <thread>
#include <vector>
#include "command/command.h"
#include "compile/compilation.h"
#include "index/merged_index.h"
#include "index/project_index.h"
#include "index/tu_index.h"
#include "support/logging.h"
#include "kota/deco/deco.h"
#include "kota/ipc/lsp/position.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/Support/raw_ostream.h"
using namespace clice;
struct Options {
DecoKV(names = {"--log-level"}; help = "Log level"; required = false;)
<std::string> log_level = "off";
DecoKV(names = {"--limit"}; help = "Max files to index (0 = all)"; required = false;)
<int> limit = 100;
DecoKV(names = {"--jobs", "-j"}; help = "Parallel compile jobs (0 = nproc)"; required = false;)
<int> jobs = 0;
DecoFlag(names = {"-h", "--help"}; help = "Show help"; required = false;)
help;
DecoInput(meta_var = "CDB"; help = "Path to compile_commands.json"; required = false;)
<std::string> cdb_path;
};
struct Timer {
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
double ms() const {
auto end = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::milli>(end - start).count();
}
void reset() {
start = std::chrono::steady_clock::now();
}
};
struct IndexStats {
std::string file;
double compile_ms = 0;
double build_index_ms = 0;
double serialize_ms = 0;
std::size_t symbols = 0;
std::size_t occurrences = 0;
std::size_t relations = 0;
std::size_t serialized_bytes = 0;
std::size_t file_count = 0;
};
struct IndexResult {
IndexStats stats;
index::TUIndex tu_index;
};
struct MergeStats {
std::size_t total_files = 0;
std::size_t total_occurrences = 0;
std::size_t total_relations = 0;
double merge_ms = 0;
double serialize_ms = 0;
std::size_t total_serialized_bytes = 0;
std::size_t shard_count = 0;
};
void print_size(std::size_t bytes) {
if(bytes < 1024)
std::print("{}B", bytes);
else if(bytes < 1024 * 1024)
std::print("{:.1f}KB", bytes / 1024.0);
else
std::print("{:.1f}MB", bytes / (1024.0 * 1024.0));
}
/// Per-task: compile one file, build TUIndex, serialize, collect stats.
/// Everything here is thread-local, no shared state.
static std::optional<IndexResult> index_one_file(CompilationDatabase& cdb,
const CompilationEntry& entry) {
auto file = cdb.resolve_path(entry.file);
auto cmds = cdb.lookup(file);
if(cmds.empty())
return std::nullopt;
auto& cmd = cmds[0];
auto argv_vec = cmd.to_argv();
CompilationParams cp;
cp.kind = CompilationKind::Indexing;
cp.directory = cmd.resolved.directory;
for(auto* arg: argv_vec)
cp.arguments.push_back(arg);
IndexResult result;
result.stats.file = std::string(file);
Timer t;
auto unit = compile(cp);
result.stats.compile_ms = t.ms();
if(!unit.completed())
return std::nullopt;
t.reset();
result.tu_index = index::TUIndex::build(unit);
result.stats.build_index_ms = t.ms();
result.stats.symbols = result.tu_index.symbols.size();
result.stats.file_count = result.tu_index.file_indices.size() + 1;
for(auto& [fid, fi]: result.tu_index.file_indices) {
result.stats.occurrences += fi.occurrences.size();
for(auto& [_, rels]: fi.relations)
result.stats.relations += rels.size();
}
result.stats.occurrences += result.tu_index.main_file_index.occurrences.size();
for(auto& [_, rels]: result.tu_index.main_file_index.relations)
result.stats.relations += rels.size();
t.reset();
std::string serialized;
llvm::raw_string_ostream os(serialized);
result.tu_index.serialize(os);
result.stats.serialize_ms = t.ms();
result.stats.serialized_bytes = serialized.size();
return result;
}
int main(int argc, const char** argv) {
auto args = deco::util::argvify(argc, argv);
auto parsed = deco::cli::parse<Options>(args);
if(!parsed.has_value()) {
std::println(stderr, "Error: {}", parsed.error().message);
return 1;
}
auto& opts = parsed->options;
if(opts.help.value_or(false) || !opts.cdb_path.has_value()) {
std::ostringstream oss;
deco::cli::write_usage_for<Options>(oss, "index_benchmark [OPTIONS] <cdb>");
std::print("{}", oss.str());
return 0;
}
auto level = spdlog::level::from_str(*opts.log_level);
clice::logging::options.level = level;
clice::logging::stderr_logger("index_benchmark", clice::logging::options);
auto num_jobs = static_cast<unsigned>(*opts.jobs);
if(num_jobs == 0)
num_jobs = std::thread::hardware_concurrency();
if(num_jobs == 0)
num_jobs = 4;
// Load CDB.
CompilationDatabase cdb;
auto count = cdb.load(*opts.cdb_path);
std::println("CDB: {} entries from {}", count, *opts.cdb_path);
auto entries = cdb.get_entries();
auto limit = static_cast<std::size_t>(*opts.limit);
if(limit > 0 && limit < entries.size())
entries = entries.take_front(limit);
std::println("Indexing {} files with {} threads...\n", entries.size(), num_jobs);
// Phase 1: Parallel compile & build TUIndex.
// Each thread picks tasks from a shared atomic counter.
// Results are written to a pre-sized vector (one slot per entry).
std::vector<std::optional<IndexResult>> results(entries.size());
std::atomic<std::size_t> next_task{0};
std::atomic<std::size_t> completed{0};
Timer phase1_wall;
{
std::vector<std::jthread> workers;
workers.reserve(num_jobs);
for(unsigned i = 0; i < num_jobs; i++) {
workers.emplace_back([&] {
while(true) {
auto idx = next_task.fetch_add(1);
if(idx >= entries.size())
break;
results[idx] = index_one_file(cdb, entries[idx]);
auto done = completed.fetch_add(1) + 1;
if(done % 100 == 0)
std::println(" ... {}/{} done", done, entries.size());
}
});
}
}
double phase1_wall_ms = phase1_wall.ms();
// Collect results on main thread.
std::vector<IndexStats> all_stats;
std::vector<std::pair<std::string, index::TUIndex>> tu_indices;
all_stats.reserve(entries.size());
tu_indices.reserve(entries.size());
double total_compile_ms = 0;
double total_build_ms = 0;
double total_serialize_ms = 0;
std::size_t total_serialized = 0;
for(auto& r: results) {
if(!r)
continue;
total_compile_ms += r->stats.compile_ms;
total_build_ms += r->stats.build_index_ms;
total_serialize_ms += r->stats.serialize_ms;
total_serialized += r->stats.serialized_bytes;
all_stats.push_back(r->stats);
tu_indices.emplace_back(std::move(r->stats.file), std::move(r->tu_index));
}
std::println("=== Phase 1: Compile & Build TUIndex ({} threads) ===", num_jobs);
std::println(" Files indexed: {}", all_stats.size());
std::println(" Wall time: {:.0f}ms", phase1_wall_ms);
std::println(" Total compile: {:.0f}ms (sum of per-thread)", total_compile_ms);
std::println(" Total build idx: {:.0f}ms (sum of per-thread)", total_build_ms);
std::println(" Total serialize: {:.0f}ms (sum of per-thread)", total_serialize_ms);
std::print(" Total TUIndex size: ");
print_size(total_serialized);
std::println("");
// Size distribution.
if(!all_stats.empty()) {
std::vector<std::size_t> sizes;
std::vector<double> compile_times;
std::vector<double> build_times;
for(auto& s: all_stats) {
sizes.push_back(s.serialized_bytes);
compile_times.push_back(s.compile_ms);
build_times.push_back(s.build_index_ms);
}
std::ranges::sort(sizes);
std::ranges::sort(compile_times);
std::ranges::sort(build_times);
auto p = [](auto& v, double pct) {
return v[static_cast<std::size_t>(v.size() * pct)];
};
std::println("\n TUIndex size distribution:");
std::print(" p50=");
print_size(p(sizes, 0.5));
std::print(" p90=");
print_size(p(sizes, 0.9));
std::print(" p99=");
print_size(p(sizes, 0.99));
std::print(" max=");
print_size(sizes.back());
std::println("");
std::println(" Compile time distribution:");
std::println(" p50={:.0f}ms p90={:.0f}ms p99={:.0f}ms max={:.0f}ms",
p(compile_times, 0.5),
p(compile_times, 0.9),
p(compile_times, 0.99),
compile_times.back());
std::println(" Build index time distribution:");
std::println(" p50={:.1f}ms p90={:.1f}ms p99={:.1f}ms max={:.1f}ms",
p(build_times, 0.5),
p(build_times, 0.9),
p(build_times, 0.99),
build_times.back());
}
// Phase 2: Merge into MergedIndex shards (main thread).
std::println("\n=== Phase 2: Merge into MergedIndex ===");
index::ProjectIndex project_index;
llvm::DenseMap<std::uint32_t, index::MergedIndex> merged;
MergeStats merge_stats;
Timer merge_timer;
llvm::DenseMap<std::uint32_t, std::string> file_contents;
auto read_content = [&](std::uint32_t path_id, llvm::StringRef path) -> llvm::StringRef {
auto it = file_contents.find(path_id);
if(it != file_contents.end())
return it->second;
auto buf = llvm::MemoryBuffer::getFile(path);
if(!buf)
return {};
auto [inserted, _] = file_contents.try_emplace(path_id, (*buf)->getBuffer().str());
return inserted->second;
};
// Pre-compute SHA256 hashes (simulates offloading to worker threads).
// With cached hash, this warms the cache so MergedIndex::merge won't recompute.
Timer hash_timer;
std::size_t hash_count = 0;
for(auto& [file, tu_index]: tu_indices) {
for(auto& [fid, fi]: tu_index.file_indices) {
fi.hash();
hash_count++;
}
tu_index.main_file_index.hash();
hash_count++;
}
double precompute_hash_ms = hash_timer.ms();
// Merge with both optimizations:
// 1. SHA256 already cached from above (MergedIndex::merge uses cached values)
// 2. Selective ProjectIndex update (skip symbols only in cache-hit files)
double project_merge_full_ms = 0;
double project_merge_selective_ms = 0;
double merged_index_merge_ms = 0;
std::size_t cache_hits = 0;
std::size_t cache_misses = 0;
for(auto& [file, tu_index]: tu_indices) {
auto& graph = tu_index.graph;
auto main_tu_path_id = static_cast<std::uint32_t>(graph.paths.size() - 1);
// First: MergedIndex merges to find out which files are new.
Bitmap new_file_ids;
Timer mt;
for(auto& [fid, fi]: tu_index.file_indices) {
auto tu_path_id = graph.path_id(fid);
auto global_path_id = project_index.path_pool.path_id(graph.paths[tu_path_id]);
auto content = read_content(global_path_id, graph.paths[tu_path_id]);
auto include_id = graph.include_location_id(fid);
bool hit = merged[global_path_id].merge(global_path_id, include_id, fi, content);
if(hit) {
cache_hits++;
} else {
cache_misses++;
new_file_ids.add(tu_path_id);
}
merge_stats.total_files++;
}
// Main file is always new.
new_file_ids.add(main_tu_path_id);
auto global_main_id = project_index.path_pool.path_id(graph.paths[main_tu_path_id]);
auto content = read_content(global_main_id, graph.paths[main_tu_path_id]);
auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch());
std::vector<index::IncludeLocation> remapped_locs;
for(auto& loc: graph.locations) {
auto remapped = loc;
remapped.path_id = project_index.path_pool.path_id(graph.paths[loc.path_id]);
remapped_locs.push_back(remapped);
}
merged[global_main_id].merge(global_main_id,
now,
std::move(remapped_locs),
tu_index.main_file_index,
content);
cache_misses++;
merged_index_merge_ms += mt.ms();
// Then: selective ProjectIndex merge (only symbols in new files).
Timer pt;
project_index.merge(tu_index, new_file_ids);
project_merge_selective_ms += pt.ms();
}
merge_stats.merge_ms = merge_timer.ms();
merge_stats.shard_count = merged.size();
// Serialize all shards to measure size.
merge_timer.reset();
for(auto& [path, shard]: merged) {
std::string buf;
llvm::raw_string_ostream os(buf);
shard.serialize(os);
merge_stats.total_serialized_bytes += buf.size();
}
merge_stats.serialize_ms = merge_timer.ms();
std::println(" Shards: {}", merge_stats.shard_count);
std::println(" SHA256 precompute (offloadable): {:.0f}ms ({} hashes)",
precompute_hash_ms,
hash_count);
std::println(" MergedIndex merge (cached hash): {:.0f}ms (hit: {}, miss: {})",
merged_index_merge_ms,
cache_hits,
cache_misses);
std::println(" ProjectIndex merge (selective): {:.0f}ms", project_merge_selective_ms);
if(!tu_indices.empty()) {
std::println(" Main-thread total: {:.0f}ms ({:.1f}ms/TU)",
merged_index_merge_ms + project_merge_selective_ms,
(merged_index_merge_ms + project_merge_selective_ms) / tu_indices.size());
}
std::println(" Serialize time: {:.0f}ms", merge_stats.serialize_ms);
std::print(" Total size: ");
print_size(merge_stats.total_serialized_bytes);
std::println("");
// Shard size distribution.
{
std::vector<std::size_t> shard_sizes;
shard_sizes.reserve(merged.size());
for(auto& [path, shard]: merged) {
std::string buf;
llvm::raw_string_ostream os(buf);
shard.serialize(os);
shard_sizes.push_back(buf.size());
}
std::ranges::sort(shard_sizes);
if(!shard_sizes.empty()) {
auto p = [&](double pct) {
return shard_sizes[static_cast<std::size_t>(shard_sizes.size() * pct)];
};
std::println("\n Shard size distribution:");
std::print(" p50=");
print_size(p(0.5));
std::print(" p90=");
print_size(p(0.9));
std::print(" p99=");
print_size(p(0.99));
std::print(" max=");
print_size(shard_sizes.back());
std::println("");
}
}
// Phase 3: Query benchmark.
std::println("\n=== Phase 3: Query Benchmark ===");
// Collect some symbol hashes to query.
llvm::SmallVector<index::SymbolHash> sample_hashes;
for(auto& [file, tu_index]: tu_indices) {
for(auto& [hash, sym]: tu_index.symbols) {
sample_hashes.push_back(hash);
if(sample_hashes.size() >= 1000)
break;
}
if(sample_hashes.size() >= 1000)
break;
}
// Occurrence lookup by offset (raw, no position conversion).
{
std::size_t hit_count = 0;
Timer t;
constexpr int rounds = 10;
for(int r = 0; r < rounds; r++) {
for(auto& [path, shard]: merged) {
for(std::uint32_t offset = 0; offset < 1000; offset += 50) {
shard.lookup(offset, [&](const index::Occurrence&) {
hit_count++;
return true;
});
}
}
}
auto elapsed = t.ms();
auto queries = merged.size() * 20 * rounds;
std::println(" Occurrence lookup (raw): {} queries in {:.1f}ms ({:.0f} q/ms, {} hits)",
queries,
elapsed,
queries / elapsed,
hit_count);
}
// Relation lookup by symbol hash (raw, no position conversion).
{
std::size_t hit_count = 0;
Timer t;
constexpr int rounds = 10;
for(int r = 0; r < rounds; r++) {
for(auto& [path, shard]: merged) {
for(auto hash: sample_hashes) {
shard.lookup(hash, RelationKind::Definition, [&](const index::Relation&) {
hit_count++;
return true;
});
}
}
}
auto elapsed = t.ms();
auto queries = merged.size() * sample_hashes.size() * rounds;
std::println(" Relation lookup (raw): {} queries in {:.1f}ms ({:.0f} q/ms, {} hits)",
queries,
elapsed,
queries / elapsed,
hit_count);
}
// PositionMapper construction benchmark.
{
using kota::ipc::lsp::PositionMapper;
using kota::ipc::lsp::PositionEncoding;
std::size_t mapper_count = 0;
std::vector<double> construct_times;
for(auto& [path, shard]: merged) {
auto content = shard.content();
if(content.empty())
continue;
Timer t;
PositionMapper mapper(content, PositionEncoding::UTF16);
construct_times.push_back(t.ms());
mapper_count++;
}
std::ranges::sort(construct_times);
if(!construct_times.empty()) {
auto sum = std::accumulate(construct_times.begin(), construct_times.end(), 0.0);
auto p = [&](double pct) {
return construct_times[static_cast<std::size_t>(construct_times.size() * pct)];
};
std::println("\n PositionMapper construction ({} shards):", mapper_count);
std::println(" Total: {:.1f}ms", sum);
std::println(" p50={:.3f}ms p90={:.3f}ms p99={:.3f}ms max={:.3f}ms",
p(0.5),
p(0.9),
p(0.99),
construct_times.back());
}
}
// Relation lookup with full position conversion (simulates real LSP query).
{
using kota::ipc::lsp::PositionMapper;
using kota::ipc::lsp::PositionEncoding;
// Pre-build mappers for all shards.
llvm::DenseMap<std::uint32_t, PositionMapper> mappers;
for(auto& [path_id, shard]: merged) {
auto content = shard.content();
if(!content.empty())
mappers.try_emplace(path_id, content, PositionEncoding::UTF16);
}
std::size_t hit_count = 0;
std::size_t convert_count = 0;
Timer t;
constexpr int rounds = 10;
for(int r = 0; r < rounds; r++) {
for(auto& [path_id, shard]: merged) {
auto mapper_it = mappers.find(path_id);
if(mapper_it == mappers.end())
continue;
auto& mapper = mapper_it->second;
for(auto hash: sample_hashes) {
shard.lookup(hash, RelationKind::Definition, [&](const index::Relation& rel) {
auto start = mapper.to_position(rel.range.begin);
auto end = mapper.to_position(rel.range.end);
if(start && end)
convert_count++;
hit_count++;
return true;
});
}
}
}
auto elapsed = t.ms();
auto queries = merged.size() * sample_hashes.size() * rounds;
std::println("\n Relation lookup + to_position:");
std::println(" {} queries in {:.1f}ms ({:.0f} q/ms, {} hits, {} converted)",
queries,
elapsed,
queries / elapsed,
hit_count,
convert_count);
}
// Phase 4: Realistic "find references" for specific high-frequency symbols.
std::println("=== Phase 4: Realistic Find-References ===");
{
using kota::ipc::lsp::PositionMapper;
using kota::ipc::lsp::PositionEncoding;
// Use the properly merged ProjectIndex symbol table.
auto& symbol_table = project_index.symbols;
// Find symbols by name to query. Collect ALL hashes whose name contains the target.
llvm::StringRef targets[] = {"vector",
"StringRef",
"SourceLocation",
"DenseMap",
"SmallVector"};
for(auto target_name: targets) {
llvm::SmallVector<index::SymbolHash> matching_hashes;
std::size_t ref_file_count = 0;
for(auto& [hash, sym]: symbol_table) {
if(sym.name == target_name) {
matching_hashes.push_back(hash);
ref_file_count += sym.reference_files.cardinality();
}
}
if(matching_hashes.empty()) {
std::println(" {}: not found in symbol table", target_name);
continue;
}
// Query all shards for this symbol's references (simulating real find-references).
std::size_t total_refs = 0;
std::size_t shards_queried = 0;
RelationKind kinds[] = {
RelationKind::Reference,
RelationKind::Definition,
RelationKind::Declaration,
};
Timer t;
for(auto& [path, shard]: merged) {
bool found = false;
for(auto target_hash: matching_hashes) {
for(auto kind: kinds) {
shard.lookup(target_hash, kind, [&](const index::Relation&) {
total_refs++;
found = true;
return true;
});
}
}
if(found)
shards_queried++;
}
auto elapsed = t.ms();
std::println(
" {} ({} hashes, {} ref_files): {} refs across {} shards, " "scanned {} shards in {:.2f}ms",
target_name,
matching_hashes.size(),
ref_file_count,
total_refs,
shards_queried,
merged.size(),
elapsed);
// Now with reference_files bitmap to skip irrelevant shards.
// We don't have path_id mapping here, so just measure the full scan above
// vs a targeted scan using reference_files count as an estimate.
}
}
std::println("");
return 0;
}