Skip to content

perf(metrics): cache usage series handles - #857

Merged
moonming merged 1 commit into
mainfrom
perf/cache-usage-metrics-1199
Jul 30, 2026
Merged

perf(metrics): cache usage series handles#857
moonming merged 1 commit into
mainfrom
perf/cache-usage-metrics-1199

Conversation

@moonming

@moonming moonming commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • cache Prometheus handles for the four aisix_llm_* usage counters
  • lazily register input, output, total, and spend independently so zero-valued dimensions remain absent
  • bound the collision-safe cache at 1024 label sets and converge concurrent first misses on one handle set

Related to api7/AISIX-Cloud#1199.

Behavior pinned

  • an all-zero or invalid usage sample does not register a metric family
  • a label set first seen with only input tokens does not create output, total, or spend series
  • a later non-zero dimension for the same labels registers lazily and preserves accumulated values
  • hash collisions retain exact label-set identity
  • concurrent first use records every increment once

Verification

  • cargo test -p aisix-obs --lib: 156 passed, 5 real-cloud smoke tests ignored, 0 failed
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check

Controlled performance test

GetBusbar OTB binaries and native topology on one ARM64 VM: 8 vCPU, gateway
cores 0-3, load generator cores 4-5, mock upstream cores 6-7, OpenAI
chat-completions path, concurrency 64, 10 seconds per run, five interleaved
baseline/optimized pairs. Baseline is exact e1da81d; both release binaries
retain function symbols.

  • GetBusbar runner commit: 3715e6c952b3a25f99dfb6a7bf9ac7436a79cdf8
  • baseline SHA-256: e92d6bde7a8b3d45137b3a72511e34ecd3a6c2f78a703041b255a6b57a861e1a
  • optimized SHA-256: 1948f6910eec3c3fab3ee8cc12b767335b396f365d4dba877cc47affcb3e330c
Pair Baseline RPS Optimized RPS Baseline p99 us Optimized p99 us
1 47,171 48,001 2,291 2,294
2 46,822 47,875 2,361 2,255
3 46,761 48,137 2,345 2,229
4 47,374 47,922 2,275 2,258
5 47,310 48,002 2,276 2,263
Median 47,171 48,001 2,291 2,258

Result: +1.76% median RPS, -1.44% median p99, zero failures in every
run. All five paired throughput comparisons improved.

Reproduction load command used by each rung:

OTB_LOADGEN_HEADERS="[[\"authorization\",\"Bearer bench-token\"]]" \
  taskset -c 4-5 otb loadgen 127.0.0.1:3000 \
  /v1/chat/completions 64 10 \
  '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}],"max_tokens":16}'

Perf evidence

Both profiles used perf record -F 99 -g --call-graph dwarf,16384 for 24
seconds under c64 load.

Inclusive symbol Baseline Optimized
Metrics::record_llm_usage 3.87% 0.32%
PrometheusRecorder::register_counter 2.58% 1.26%
metrics::key::Key::eq 2.69% 1.67%

The optimized usage-cache lookup itself accounts for 0.12% inclusive CPU.
After normalizing for throughput, record_llm_usage CPU cost per request falls
by about 92%.

Summary by CodeRabbit

  • Performance Improvements
    • Improved LLM usage metrics collection by creating counters only for usage data that is present.
    • Added caching and reuse of usage metric series to reduce overhead.
    • Added safeguards to keep metric caching bounded and reliable during concurrent activity.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

LLM usage metrics now cache usage label series with bounded, collision-safe lookup and lazily initialized counters. Recording handles non-zero token fields and optional micro-USD spend, with tests covering reuse, bounds, collisions, and concurrency.

Changes

LLM usage metrics

Layer / File(s) Summary
Usage-series cache and lazy handles
crates/aisix-obs/src/metrics.rs
Adds bounded usage-series caching, owned-label matching, stable hashing, and OnceLock-backed counter handles.
Usage recording and spend conversion
crates/aisix-obs/src/metrics.rs
Updates LLM usage recording to increment only present dimensions and converts positive finite spend to optional micro-USD integers.
Caching and concurrency validation
crates/aisix-obs/src/metrics.rs
Tests lazy creation, reuse, zero-value omission, cache bounds, hash collisions, and concurrent initialization.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: jarvis9443

Sequence Diagram(s)

sequenceDiagram
  participant Metrics as Metrics::record_llm_usage
  participant Cache as UsageSeriesCache
  participant Counters as UsageSeriesHandles
  Metrics->>Cache: Lookup or insert usage label series
  Cache-->>Metrics: Return cached handles
  Metrics->>Counters: Initialize required counters
  Metrics->>Counters: Increment non-zero usage dimensions
Loading
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning New tests stay in metrics.rs unit scope; they assert internal cache/render behavior but never drive a real request→scrape path, so E2E completeness is missing. Add an integration/E2E test that exercises the proxy/request path end-to-end, then scrapes Prometheus and verifies lazy zero-dimension and spend-metric behavior.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed Patch only adds lazy Prometheus usage-cache logic; no logging, serialization, secret persistence, auth, or TLS code changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: caching usage series handles for metrics performance.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/cache-usage-metrics-1199

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-obs/src/metrics.rs`:
- Around line 1574-1579: Update spend_micro_usd to reject non-finite scaled
values and any rounded micro-USD value outside the representable u64 range
before casting, returning None instead. Add boundary tests covering scaled
infinity and values at or beyond the u64 maximum while preserving valid positive
conversions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 779fe5ef-45c4-45be-850e-b16ae1fad423

📥 Commits

Reviewing files that changed from the base of the PR and between e1da81d and 0fae0b5.

📒 Files selected for processing (1)
  • crates/aisix-obs/src/metrics.rs

Comment on lines +1574 to +1579
fn spend_micro_usd(self) -> Option<u64> {
if !self.spend_usd.is_finite() || self.spend_usd <= 0.0 {
return None;
}
let micro_usd = (self.spend_usd * 1_000_000.0).round();
(micro_usd > 0.0).then_some(micro_usd as u64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

According to the official Rust Reference, what is the result of casting an out-of-range or infinite f64tou64withas?

💡 Result:

According to the Rust Reference and established language behavior, casting an infinite f64 to u64 using the as keyword results in the maximum value of the target integer type, which is u64::MAX [1][2]. In Rust, float-to-integer casts using as are defined to be saturating [1]. The conversion process follows these specific rules: 1. The floating-point value is rounded toward zero [1]. 2. If the value is NaN, it is converted to 0 [1]. 3. If the value is positive infinity or exceeds the maximum representable value of the target integer type, it is saturated to the maximum value of that type (u64::MAX) [1][2]. 4. If the value is negative infinity or is less than the minimum representable value of the target integer type (which is 0 for u64), it is saturated to the minimum value of that type (0) [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## locate file and inspect relevant symbols"
wc -l crates/aisix-obs/src/metrics.rs
rg -n "struct LlmUsage|impl LlmUsage|fn spend_micro_usd|spend_usd|spend_micro_usd" crates/aisix-obs/src/metrics.rs

echo
echo "## inspect spend conversion and nearby usage"
sed -n '1520,1595p' crates/aisix-obs/src/metrics.rs
sed -n '1595,1680p' crates/aisix-obs/src/metrics.rs

echo
echo "## tests around spend_micro_usd if present"
rg -n "spend_micro_usd|use_.*|LlmUsage" crates/aisix-obs/src/metrics.rs | tail -n 80

echo
echo "## read-only Rust behavior probe for f64-to-u64 casts"
cat >/tmp/f64_u64_sat.rs <<'RS'
fn main() {
    for (name, value) in [
        ("inf_infinity", f64::INFINITY),
        ("nan", f64::NAN),
        ("round_to_9007199254740993", (u64::MAX as f64).round()),
        ("round_to_9007199254740992", (u64::MAX as f64 - 1.0_f64).round()),
        ("round_to_9007199254740991", (u64::MAX as f64 - 2.0_f64).round()),
        ("exact_9007199254740991", (u64::MAX as f64 - 2.0_f64).round()),
    ] {
        println!("{name}: input={value} saturates_to_as_u64={:?}", value as u64);
    }
    let vals = [u64::MAX as f64, (u64::MAX as f64) - 1.0, (u64::MAX as f64) - 2.0];
    for v in vals {
        println!("round({:.1?}) = {:.1?}; as u64 = {:?}", v, v.round(), v.round() as u64);
    }
}
RS
rustc /tmp/f64_u64_sat.rs -o /tmp/f64_u64_sat 2>/tmp/scratch_rustc.out || { cat /tmp/scratch_rustc.out; exit 0; }
/tmp/f64_u64_sat

Repository: api7/aisix

Length of output: 6410


🌐 Web query:

Rust Reference unsafe_unchecked float to primitive integer conversion f64 u64 saturating

💡 Result:

In Rust, there is no direct equivalent of a "saturating unchecked" float-to-integer conversion method in the standard library. The existing tool for unchecked conversion, f64::to_int_unchecked, strictly requires that the input value is within the range of the target integer type [1][2]. The standard approach for unchecked conversion is: f64::to_int_unchecked This method rounds the float toward zero and performs a raw conversion to the target integer type [1]. Safety Contract: To use this method safely, the caller must guarantee that: 1. The value is not NaN [1]. 2. The value is not infinite [1]. 3. The value is representable in the target integer type after truncating its fractional part [1]. If these conditions are not met, the operation results in undefined behavior [3]. Regarding saturating behavior: The standard 'as' cast in Rust for floating-point to integer conversion is defined to be saturating [4][3]. If you require saturating semantics for performance or logic, you should use the standard 'as' cast (e.g., value as u64), which safely handles out-of-range values and NaN by clamping to the integer bounds (or zero for NaN) [5][4]. If you specifically require the performance of an unchecked cast but also need saturation, you must implement the range checks manually before calling to_int_unchecked, or accept the safety guarantees of the standard 'as' cast [6][3].

Citations:


Reject overflowed micro-USD values before casting.

Scaling a valid spend_usd can produce an infinite value; u64 casts saturate, so this can increment spend by u64::MAX instead of returning None. Reject non-finite scaled values and values that would overflow u64; add boundary tests.

Proposed fix
         let micro_usd = (self.spend_usd * 1_000_000.0).round();
-        (micro_usd > 0.0).then_some(micro_usd as u64)
+        if !micro_usd.is_finite() || micro_usd <= 0.0 || micro_usd >= u64::MAX as f64 {
+            return None;
+        }
+        Some(micro_usd as u64)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn spend_micro_usd(self) -> Option<u64> {
if !self.spend_usd.is_finite() || self.spend_usd <= 0.0 {
return None;
}
let micro_usd = (self.spend_usd * 1_000_000.0).round();
(micro_usd > 0.0).then_some(micro_usd as u64)
fn spend_micro_usd(self) -> Option<u64> {
if !self.spend_usd.is_finite() || self.spend_usd <= 0.0 {
return None;
}
let micro_usd = (self.spend_usd * 1_000_000.0).round();
if !micro_usd.is_finite() || micro_usd <= 0.0 || micro_usd >= u64::MAX as f64 {
return None;
}
Some(micro_usd as u64)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-obs/src/metrics.rs` around lines 1574 - 1579, Update
spend_micro_usd to reject non-finite scaled values and any rounded micro-USD
value outside the representable u64 range before casting, returning None
instead. Add boundary tests covering scaled infinity and values at or beyond the
u64 maximum while preserving valid positive conversions.

@moonming
moonming merged commit fb80aaf into main Jul 30, 2026
10 checks passed
@moonming
moonming deleted the perf/cache-usage-metrics-1199 branch July 30, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant