1変数で管理できるレート計測用カウンター
1. はじめに:カウンターとレートの違い
total_requests += 1 のように加算していくだけで、累積値を簡単に把握できます。
2. 従来の実装方法
2.1 バケットを使った方法
[12, 9, 10, 13, ...] // 直近60秒分のバケットのイメージ
2.2 指数減衰を使った方法
value(last): 最後に更新した時点での値last: 最後に更新した時刻now: 現在時刻duration: 減衰の時間スケール(時定数・実効的な窓幅)
last)」と「その時点での値(value(last))」という2つの変数を保持する必要があります。
3. 1変数で指数減衰を管理できる提案手法
3.1 概要
pointer_time)」という1つの変数だけでこれを管理します。
pointer_time)を保存する点にあります。
pointer_time は実際のイベント発生時刻ではなく、指数減衰曲線の位置を表現するための仮想的なパラメータだということです。
pointer_timeが現在時刻より未来にある: 値が大きいpointer_timeが現在時刻に近い: 値が1に近いpointer_timeが現在時刻より過去にある: 値が減衰している
3.2 カウントアップの方法
- Decode(復元): 現在時刻 \(t\) における減衰後の値を計算
- Increment(加算): 復元した値に増分 \(x\) を足す
- Encode(更新): 新しい値を元に、次回の計算に使う
pointer_timeを逆算して保存
pointer_time から現在時刻 \(t\) における値を復元します。
pointer_time を逆算します。
pointer_time だけです。
3.3 なぜ更新時刻が不要になるのか?
last(最後に更新した時刻)は相殺されて消えてしまいます。つまり、「最後に更新した時刻」と「その時刻での値」という2つの情報は、事実上 pointer_time という1つの変数に完全に集約できるのです。
3.4 duration と半減期の関係
duration は、半減期ではなく、指数減衰カーネルの積分値(実効的な窓幅)として定義しています。経過時間 \(a\) に対する重みを \(w(a) = \exp(-a / \mathrm{duration})\) とすると、その重みの総面積は以下のように duration そのものになります。
duration = 1 なら「毎秒」、duration = 60 なら「毎分」、duration = 3600 なら「毎時」のレートとしてそのまま直感的に読める値になります。
duration = 60 のカウンターは「半減期60秒」ではなく、「半減期約41.6秒」のカウンターとなります。本稿ではレート表示を直感的に分かりやすくするため、半減期ではなく実効窓幅としての duration を採用しています。
4. 実装方法:固定小数点の時刻として保存する
pointer_time をそのまま浮動小数点数として扱うのではなく、整数に変換して AtomicI64 に保存することです。状態が1変数で済むため、スレッドセーフな読み出しと更新を1つのCAS(Compare-And-Swap)操作で完結させることができます。
4.1 保存形式と倍率
pointer_time に一定の倍率(scale)を掛け、丸めた固定小数点整数を保存します。
scale = 1_000_000 とした場合、保存単位は1マイクロ秒になります。i64 の最大値は約 \(9.22 \times 10^{18}\) であるため、この倍率でも表現できる時間幅は約29万年にも及びます。monotonic clock(単調増加時計)の起点からの経過秒を保存するような用途では、オーバーフローが問題になることはまずありません。
duration)を \(\tau\) とすると、1回の加算で pointer_time が動く量はおおよそ \(\tau / v\) です。定常状態では \(v \simeq \mathrm{rate} \times \tau\) となるため、整数値の変化量はおおよそ次のように見積もれます。
scale = 10_000 の場合、1カウンターあたり毎秒数千イベント程度までなら実用的ですが、毎秒1万イベントを超えるような用途では量子化誤差の影響が顕著になります。汎用的な実装としては scale = 1_000_000 を既定値としておくと、分解能とオーバーフロー余裕のバランスが良く扱いやすいでしょう。
4.2 Atomic変数での管理
AtomicI64 だけで表現できます。duration や倍率(scale)はカウンター自体の設定値であり、更新のたびにアトミックに書き換える必要のある状態ではありません。
use std::sync::atomic::{AtomicI64, Ordering};
const SCALE: f64 = 1_000_000.0;
pub struct RateCounter {
pointer_time: AtomicI64,
duration_secs: f64,
}
impl RateCounter {
pub fn new(duration_secs: f64) -> Self {
assert!(duration_secs.is_finite() && duration_secs > 0.0);
Self {
pointer_time: AtomicI64::new(i64::MIN),
duration_secs,
}
}
}
i64::MIN を入れておきます。これは scale = 1_000_000 の場合、約29万年前の pointer_time に相当するため、現在時刻でデコードすると値は実質的に0になります。未更新(空)状態のための特別な分岐処理を設けず、通常の減衰計算だけで一貫して扱えるのがこの方法の大きな利点です。
4.3 デコード、加算、再エンコード
pointer_time に戻し、現在時刻との差から値を復元します。前述の通り、初期値の i64::MIN も単に「非常に古い時刻」として処理されるため、ここでも特別な分岐は不要です。
fn decode_value(raw: i64, now_secs: f64, duration_secs: f64) -> f64 {
let pointer_time = raw as f64 / SCALE;
((pointer_time - now_secs) / duration_secs).exp()
}
fn encode_time(pointer_time: f64) -> i64 {
(pointer_time * SCALE)
.round()
.clamp(i64::MIN as f64, i64::MAX as f64) as i64
}
pointer_time にエンコードします。このとき、もし別のスレッドが先に値を更新していた場合はCASが失敗するため、新しい保存値を使って計算をやり直します(リトライループ)。
impl RateCounter {
pub fn value_at(&self, now_secs: f64) -> f64 {
let raw = self.pointer_time.load(Ordering::Relaxed);
decode_value(raw, now_secs, self.duration_secs)
}
pub fn increment_at(&self, now_secs: f64, x: f64) {
assert!(now_secs.is_finite());
assert!(x.is_finite() && x > 0.0);
let mut raw = self.pointer_time.load(Ordering::Relaxed);
loop {
let old_value = decode_value(raw, now_secs, self.duration_secs);
let new_value = old_value + x;
let new_pointer_time = now_secs + self.duration_secs * new_value.ln();
let new_raw = encode_time(new_pointer_time);
match self.pointer_time.compare_exchange_weak(
raw,
new_raw,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(actual) => raw = actual,
}
}
}
}
Ordering::Relaxed で十分です。もし、イベント処理本体との happens-before 関係をカウンター値の可視性によって制御したい特別な事情がある場合のみ、より強い順序(Acquire / Release など)を検討してください。
duration_secs が正の有限値であり、now_secs と増分 x も有限値であることを前提としています。これらの条件を満たす限り、初期値の i64::MIN は非常に古い pointer_time として自然に扱われ、最初の加算操作によって適切な値へと更新されます。
4.4 実装上の注意点
Instant からの経過秒数を利用するのが安全で扱いやすいでしょう。
use std::time::Instant; let origin = Instant::now(); let now_secs = origin.elapsed().as_secs_f64();
exp 関数の計算結果が0にアンダーフローすることがあります。しかし、これは実質的に値が完全に0まで減衰したと見なせる状態であるため、ほとんどの用途で問題にはなりません。初期値に i64::MIN を用いるのも、まさにこの性質を利用したものです。
AtomicI64 に pointer_time * 1_000_000 を保存し、初期値を i64::MIN とした上で、CASループを用いてデコード・加算・再エンコードを行う」という構成が非常に扱いやすいと言えます。
5. 数理的な背景
pointer_time に相当します。また、この対数領域における加算(インクリメント操作)は、機械学習などでよく知られる log-sum-exp の計算と等価になります。
pointer_time)へエンコードする」という手順を踏むことで、直感的かつ効率的に処理しています。
6. まとめ
pointer_time)だけでこれを実現できます。
- 現在値は \(\exp((\mathrm{pointer\_time} - \mathrm{now}) / \mathrm{duration})\) で復元可能
- 加算は保存値をデコード、加算、再エンコードの3ステップで完了
AtomicI64のようなアトミック変数で管理可能
7. 参考文献
- Cohen, E., & Strauss, M. J. (2006). "Maintaining Time-Decaying Stream Aggregates". Journal of Algorithms, 59, 19-36. 時間減衰するストリーム集計を一般的に扱うための基礎文献です。
- Cormode, G., Korn, F., & Tirthapura, S. (2008). "Exponentially Decayed Aggregates on Data Streams". ICDE 2008. 指数減衰集計をデータストリーム上で扱う手法を整理した文献で、本稿の数理的背景に近い内容です。
- Cormode, G., Shkapenyuk, V., Srivastava, D., & Xu, B. (2009). "Forward Decay: A Practical Time Decay Model for Streaming Systems". ICDE 2009. 固定された時点から前向きに減衰を表現する考え方を導入しており、実装しやすい時間減衰モデルの背景として参考になります。
- Datar, M., Gionis, A., Indyk, P., & Motwani, R. (2002). "Maintaining Stream Statistics over Sliding Windows". SIAM Journal on Computing. スライディングウィンドウ型のストリーム統計を扱う古典的な文献で、バケットや窓幅ベースの方法との対比に関係します。
- NIST/SEMATECH Engineering Statistics Handbook, "EWMA Control Charts". EWMAの基本的な考え方と、過去の観測値に指数的に小さくなる重みを与える監視手法の実務的な背景を説明しています。
- Dropwizard Metrics documentation, "Meters". 実務的なメトリクスライブラリにおけるレート計測の例で、平均レートに加えて1分、5分、15分の移動平均を扱う設計の参考になります。
- SciPy documentation,
scipy.special.logsumexp. log-sum-exp を数値的に安定して計算するための標準的な関数で、本稿の対数領域での加算の理解に役立ちます。
最終更新日: 2026年6月19日