1変数で管理できるレート計測用カウンター

2026年6月19日公開

1変数で管理できるレート計測用カウンター

1. はじめに:カウンターとレートの違い

Webアプリケーションやシステムの運用では、リクエスト数やエラー数、ジョブの処理件数などを計測するために、単純なカウンターがよく使われます。例えば、total_requests += 1 のように加算していくだけで、累積値を簡単に把握できます。
しかし、実際の運用では単なる「累積値」だけでなく、単位時間あたりの「変化量(レート)」が重要になるケースが少なくありません。たとえば、累積エラー数が1,000件というアラートだけでは、深刻度を判断できません。1週間かけて徐々に増えた1,000件なら大きな問題ではないかもしれませんが、直近の10秒間で突然1,000件発生した場合、致命的な障害の可能性が高いでしょう。
この記事では、このような「変化量(レート)」を軽量に計測するための手法を解説します。通常、指数減衰の計算は2つの変数を用いて表現されますが、本稿ではこれを1つの変数だけで実現する方法について説明します。

2. 従来の実装方法

「変化量(レート)」を計測するための代表的な実装方法として、主に2つのアプローチがあります。

2.1 バケットを使った方法

直感的なものとして、一定時間ごとにバケットを用意し、その区間内の件数を数える方法があります。例えば、「直近1分間のリクエスト数」を知りたい場合、1秒ごとのバケットを60個保持します。
[12, 9, 10, 13, ...]  // 直近60秒分のバケットのイメージ
この方法は分かりやすい反面、複数の状態を保持する必要があります。そのため、計測対象の時間幅を広げたり、システム全体のメトリクス数が増えたりすると、メモリや処理のオーバーヘッドが大きくなってしまいます。

2.2 指数減衰を使った方法

もう一つの有力な方法は、「古いイベントほど重みを軽くする」指数減衰の考え方に基づくものです。これはEWMA(指数加重移動平均)などとも呼ばれる手法で、以下の計算式によって現在値を求めます。
\[ \operatorname{value}(\mathrm{now}) = \operatorname{value}(\mathrm{last}) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \]
ここでの各変数の意味は以下の通りです。
  • value(last): 最後に更新した時点での値
  • last: 最後に更新した時刻
  • now: 現在時刻
  • duration: 減衰の時間スケール(時定数・実効的な窓幅)
この計算式をそのままプログラムに実装しようとすると、「最後に更新した時刻(last)」と「その時点での値(value(last))」という2つの変数を保持する必要があります。

3. 1変数で指数減衰を管理できる提案手法

3.1 概要

通常、指数減衰カウンターを実装するには前述の通り2つの状態を保持する必要がありますが、提案手法では「時刻風のパラメータ(pointer_time)」という1つの変数だけでこれを管理します。
この手法のポイントは、過去の「値」や「時刻」をそのまま保存するのではなく、現在時刻と減衰の時間スケールから現在値を復元できるパラメータ(pointer_time)を保存する点にあります。
\[ \operatorname{value}(\mathrm{now}) = \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{now}}{\mathrm{duration}}\right) \]
ここで注意すべきなのは、pointer_time は実際のイベント発生時刻ではなく、指数減衰曲線の位置を表現するための仮想的なパラメータだということです。
直感的には以下のように解釈できます。
  • pointer_time が現在時刻より未来にある: 値が大きい
  • pointer_time が現在時刻に近い: 値が1に近い
  • pointer_time が現在時刻より過去にある: 値が減衰している
このように、値を単なる「特定の時刻における点」としてではなく、「指数減衰曲線の位置」として記憶することで、保持すべき変数を1つに削減しているのです。

3.2 カウントアップの方法

では、この1変数カウンターでどのように値を加算するのでしょうか。ある時刻 \(t\) に値を \(x\) 増やす場合、以下の3ステップで処理を行います。
  1. Decode(復元): 現在時刻 \(t\) における減衰後の値を計算
  2. Increment(加算): 復元した値に増分 \(x\) を足す
  3. Encode(更新): 新しい値を元に、次回の計算に使う pointer_time を逆算して保存
まず Decode では、保存されている pointer_time から現在時刻 \(t\) における値を復元します。
\[ \mathrm{old\_value} = \exp\left(\frac{\mathrm{pointer\_time} - t}{\mathrm{duration}}\right) \]
次に Increment では、復元した値に増分 \(x\) を足します。
\[ \mathrm{new\_value} = \mathrm{old\_value} + x \]
最後に Encode では、加算後の新しい値を表す pointer_time を逆算します。
\[ \mathrm{pointer\_time}_{\mathrm{new}} = t + \mathrm{duration} \times \log(\mathrm{new\_value}) \]
最終的に保存するのは、更新後の pointer_time だけです。

3.3 なぜ更新時刻が不要になるのか?

「最後に更新した時刻」を保持しなくてよいのは一見不思議に思えますが、数式を展開するとその理由が分かります。一般的な指数減衰の式は以下の通りです。
\[ \operatorname{value}(\mathrm{now}) = \operatorname{value}(\mathrm{last}) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \]
ここで、\(\operatorname{value}(\mathrm{last})\) を提案手法の表現 \(\exp((\mathrm{pointer\_time} - \mathrm{last}) / \mathrm{duration})\) に置き換えると、次のようになります。
\[ \begin{aligned} \operatorname{value}(\mathrm{now}) &= \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{last}}{\mathrm{duration}}\right) \times \exp\left(-\frac{\mathrm{now} - \mathrm{last}}{\mathrm{duration}}\right) \\ &= \exp\left(\frac{\mathrm{pointer\_time} - \mathrm{now}}{\mathrm{duration}}\right) \end{aligned} \]
このように、last(最後に更新した時刻)は相殺されて消えてしまいます。つまり、「最後に更新した時刻」と「その時刻での値」という2つの情報は、事実上 pointer_time という1つの変数に完全に集約できるのです。

3.4 duration と半減期の関係

本稿における duration は、半減期ではなく、指数減衰カーネルの積分値(実効的な窓幅)として定義しています。経過時間 \(a\) に対する重みを \(w(a) = \exp(-a / \mathrm{duration})\) とすると、その重みの総面積は以下のように duration そのものになります。
\[ \int_0^\infty \exp\left(-\frac{a}{\mathrm{duration}}\right) da = \mathrm{duration} \]
そのため、毎秒 \(r\) 回のイベントが一定のペースで発生する場合、指数減衰カウンターの定常値は \(r \times \mathrm{duration}\) に収束します。これにより、duration = 1 なら「毎秒」、duration = 60 なら「毎分」、duration = 3600 なら「毎時」のレートとしてそのまま直感的に読める値になります。
これに対して、半減期 \(h\) は重みが半分になるまでの時間として定義され、以下を満たす \(h\) となります。
\[ \exp\left(-\frac{h}{\mathrm{duration}}\right) = \frac{1}{2} \]
これを解くと、半減期は次のようになります。
\[ h = \mathrm{duration} \times \ln 2 \]
したがって、duration = 60 のカウンターは「半減期60秒」ではなく、「半減期約41.6秒」のカウンターとなります。本稿ではレート表示を直感的に分かりやすくするため、半減期ではなく実効窓幅としての duration を採用しています。

4. 実装方法:固定小数点の時刻として保存する

この手法を実装する上での要点は、pointer_time をそのまま浮動小数点数として扱うのではなく、整数に変換して AtomicI64 に保存することです。状態が1変数で済むため、スレッドセーフな読み出しと更新を1つのCAS(Compare-And-Swap)操作で完結させることができます。

4.1 保存形式と倍率

具体的には、秒単位の pointer_time に一定の倍率(scale)を掛け、丸めた固定小数点整数を保存します。
\[ \mathrm{raw} = \operatorname{round}(\mathrm{pointer\_time} \times \mathrm{scale}) \]
例えば scale = 1_000_000 とした場合、保存単位は1マイクロ秒になります。i64 の最大値は約 \(9.22 \times 10^{18}\) であるため、この倍率でも表現できる時間幅は約29万年にも及びます。monotonic clock(単調増加時計)の起点からの経過秒を保存するような用途では、オーバーフローが問題になることはまずありません。
一方で、倍率を小さくしすぎると、高レート時に1イベント分の更新が丸め誤差によって消えやすくなってしまいます。現在の値を \(v\)、増分を1、時間スケール(duration)を \(\tau\) とすると、1回の加算で pointer_time が動く量はおおよそ \(\tau / v\) です。定常状態では \(v \simeq \mathrm{rate} \times \tau\) となるため、整数値の変化量はおおよそ次のように見積もれます。
\[ \mathrm{raw\_delta} \simeq \frac{\mathrm{scale}}{\mathrm{rate}} \]
つまり、scale = 10_000 の場合、1カウンターあたり毎秒数千イベント程度までなら実用的ですが、毎秒1万イベントを超えるような用途では量子化誤差の影響が顕著になります。汎用的な実装としては scale = 1_000_000 を既定値としておくと、分解能とオーバーフロー余裕のバランスが良く扱いやすいでしょう。

4.2 Atomic変数での管理

保存値が1つの整数に収まるため、カウンター本体の状態は 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 実装上の注意点

時刻の取得には、システム時刻(Wall-clock time)ではなく、単調増加する monotonic clock を使用してください。システム時刻はNTPによる補正や手動での変更によって巻き戻る可能性があり、指数減衰の計算において値が不自然に急増減する原因となります。Rustであれば、プロセス開始時に取得した Instant からの経過秒数を利用するのが安全で扱いやすいでしょう。
use std::time::Instant;

let origin = Instant::now();
let now_secs = origin.elapsed().as_secs_f64();
また、非常に長い時間更新されていないカウンターの場合、デコード時の exp 関数の計算結果が0にアンダーフローすることがあります。しかし、これは実質的に値が完全に0まで減衰したと見なせる状態であるため、ほとんどの用途で問題にはなりません。初期値に i64::MIN を用いるのも、まさにこの性質を利用したものです。
まとめると、実用的な実装のベースとしては、「AtomicI64pointer_time * 1_000_000 を保存し、初期値を i64::MIN とした上で、CASループを用いてデコード・加算・再エンコードを行う」という構成が非常に扱いやすいと言えます。

5. 数理的な背景

本手法は、より一般的な「指数減衰集計(exponentially decayed aggregate)」の特殊なケースとして位置づけることができます。
時刻 \(t_i\) に重み \(w_i\) のイベントが発生したときの指数減衰カウント \(D(t)\) は、次のように定義できます。
\[ D(t) = \sum_i w_i \times \exp\left(-\frac{t - t_i}{\tau}\right) \]
これを変形すると、次のようになります。
\[ D(t) = \exp\left(-\frac{t}{\tau}\right) \times \sum_i w_i \times \exp\left(\frac{t_i}{\tau}\right) \]
ここで、読み出し時刻 \(t\) に依存する部分はシグマ(\(\sum\))の外に出すことができます。したがって、システムが履歴として保持しておくべき本質的な状態 \(S\) は以下の部分のみになります。
\[ S = \sum_i w_i \times \exp\left(\frac{t_i}{\tau}\right) \]
この \(S\) を対数領域に変換すると、次のようになります。
\[ p = \tau \times \log(S) \]
この \(p\) が、まさに提案手法における pointer_time に相当します。また、この対数領域における加算(インクリメント操作)は、機械学習などでよく知られる log-sum-exp の計算と等価になります。
\[ p_{\mathrm{new}} = \tau \times \log\left( \exp\left(\frac{p_{\mathrm{old}}}{\tau}\right) + x \times \exp\left(\frac{t}{\tau}\right) \right) \]
実際の実装では、これを直接 log-sum-exp として計算するのではなく、「一度実数空間にデコードして加算し、再度対数空間(pointer_time)へエンコードする」という手順を踏むことで、直感的かつ効率的に処理しています。

6. まとめ

システムの運用監視などにおいて、「累積値」だけでなく「変化量(レート)」をリアルタイムに把握することは非常に重要です。通常の指数減衰カウンターを実装するには2つの状態変数を保持する必要がありますが、本稿で提案した手法を用いれば、1つの時刻風パラメータ(pointer_time)だけでこれを実現できます。
この手法の特徴は以下の通りです。
  • 現在値は \(\exp((\mathrm{pointer\_time} - \mathrm{now}) / \mathrm{duration})\) で復元可能
  • 加算は保存値をデコード、加算、再エンコードの3ステップで完了
  • AtomicI64 のようなアトミック変数で管理可能
特定の厳密な期間内の件数を正確にカウントする用途には向きませんが、負荷のスパイクやエラー増加の「勢い(現在の傾向)」を大まかに把握するための、非常に軽量で実用的な手法です。

7. 参考文献

本稿の指数減衰集計、スライディングウィンドウとの対比、EWMAによるレート計測、および log-sum-exp の数値計算は、以下の文献および実装標準を参考にしています。
  1. Cohen, E., & Strauss, M. J. (2006). "Maintaining Time-Decaying Stream Aggregates". Journal of Algorithms, 59, 19-36. 時間減衰するストリーム集計を一般的に扱うための基礎文献です。
  2. Cormode, G., Korn, F., & Tirthapura, S. (2008). "Exponentially Decayed Aggregates on Data Streams". ICDE 2008. 指数減衰集計をデータストリーム上で扱う手法を整理した文献で、本稿の数理的背景に近い内容です。
  3. Cormode, G., Shkapenyuk, V., Srivastava, D., & Xu, B. (2009). "Forward Decay: A Practical Time Decay Model for Streaming Systems". ICDE 2009. 固定された時点から前向きに減衰を表現する考え方を導入しており、実装しやすい時間減衰モデルの背景として参考になります。
  4. Datar, M., Gionis, A., Indyk, P., & Motwani, R. (2002). "Maintaining Stream Statistics over Sliding Windows". SIAM Journal on Computing. スライディングウィンドウ型のストリーム統計を扱う古典的な文献で、バケットや窓幅ベースの方法との対比に関係します。
  5. NIST/SEMATECH Engineering Statistics Handbook, "EWMA Control Charts". EWMAの基本的な考え方と、過去の観測値に指数的に小さくなる重みを与える監視手法の実務的な背景を説明しています。
  6. Dropwizard Metrics documentation, "Meters". 実務的なメトリクスライブラリにおけるレート計測の例で、平均レートに加えて1分、5分、15分の移動平均を扱う設計の参考になります。
  7. SciPy documentation, scipy.special.logsumexp. log-sum-exp を数値的に安定して計算するための標準的な関数で、本稿の対数領域での加算の理解に役立ちます。

最終更新日: 2026年6月19日