Skip to content

Hourly report re-scans the whole corpus on every call (~7.7s, no cache) #224

Description

@Nanako0129

The Hourly lens re-scans the entire message corpus on every call, and the fold it runs while scanning allocates a handful of strings per message. On a ~114 day corpus that is roughly 7.7 seconds, every time, with no warming.

Measured

Three consecutive calls in one process, plus two slice variants and a control, on a corpus spanning 114 days:

Call Time Result size
TBCore.hourlyReport(year: nil, clients: nil) — 1st 7771 ms 927 entries
same — 2nd 7415 ms 927 entries
same — 3rd 7737 ms 927 entries
hourlyReport(clients: ["claude"]) 3814 ms
hourlyReport(year: "2026") 4846 ms
TBCore.graph(year: nil) — control 3769 ms

Two things stand out.

There is no warming at all. The second and third calls cost the same as the first, so this is not cold-start I/O being amortised. By contrast tb_window_usage holds a result cache (crates/tb_core_ffi/src/window_usage.rs) and goes from 67 s cold to about 950 ms warm on the same machine.

graph scans the same corpus and costs half. That puts the scan floor at roughly 3.8 s and leaves roughly 3.9 s in the per-message fold.

Where the fold time goes

vendor/tokscale-core/src/lib.rs, get_hourly_report, inside the scan_messages_streaming callback:

let hour_key = match Local.timestamp_opt(ts_secs, 0) {
    chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:00").to_string(),
    _ => format!("{} 00:00", msg.date),
};
let entry = hour_map.entry(hour_key).or_default();
entry.clients.insert(msg.client.clone());
entry.models.insert(aliases.fold(normalize_syntactic(&msg.model_id)));

Every message allocates:

  • one String for hour_key, which is then hashed as a 16-byte string and immediately dropped on the overwhelmingly common already-present path
  • one String clone for msg.client, discarded on insert whenever that client is already in the hour's set, which it almost always is
  • at least one more inside normalize_syntactic and aliases.fold for the model id

The corpus here is on the order of 800k messages, so that is several million allocations per call to produce 927 rows.

Suggested direction

Not yet attempted, listed in the order I would try them:

  1. Key hour_map by the epoch hour as an i64 and format the display string once per surviving entry at the end. Turns millions of string hashes into integer hashes.
  2. Check contains before insert on clients and models. Distinct clients per hour is one or two, so the hit path becomes allocation-free.
  3. Only then consider a result cache in crates/tb_core_ffi, in the shape window_usage.rs already uses. Worth doing after 1 and 2 rather than instead of them, since a cache leaves the first call as slow as it is now.

Verification this needs

The fold feeds Daily, Monthly and Hourly, so speed alone is not the acceptance condition. Any change here has to show the shipping code producing byte-identical output on real data before and after: all 927 entries compared field by field, not just a matching total.

Why it is filed rather than fixed

Surfaced while measuring whether a weekday-by-hour heatmap could be added to the Stats lens. The heatmap genuinely needs hour granularity, and the graph payload only carries day granularity (Contribution.date), so hourlyReport is the only source. The cost is not caused by that feature though: the Hourly lens already pays it on every visit today.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions