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:
- 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.
- Check
contains before insert on clients and models. Distinct clients per hour is one or two, so the hit path becomes allocation-free.
- 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.
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:
TBCore.hourlyReport(year: nil, clients: nil)— 1sthourlyReport(clients: ["claude"])hourlyReport(year: "2026")TBCore.graph(year: nil)— controlTwo 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_usageholds 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.graphscans 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 thescan_messages_streamingcallback:Every message allocates:
Stringforhour_key, which is then hashed as a 16-byte string and immediately dropped on the overwhelmingly common already-present pathStringclone formsg.client, discarded on insert whenever that client is already in the hour's set, which it almost always isnormalize_syntacticandaliases.foldfor the model idThe 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:
hour_mapby the epoch hour as ani64and format the display string once per surviving entry at the end. Turns millions of string hashes into integer hashes.containsbeforeinsertonclientsandmodels. Distinct clients per hour is one or two, so the hit path becomes allocation-free.crates/tb_core_ffi, in the shapewindow_usage.rsalready 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), sohourlyReportis the only source. The cost is not caused by that feature though: the Hourly lens already pays it on every visit today.