LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
cache_miss_rmf.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_API_CACHE_CACHE_MISS_RMF_H
6#define LINE_API_CACHE_CACHE_MISS_RMF_H
7
8/**
9 * @file
10 * @ingroup api_cache
11 * Refined mean field (RMF) miss rates of a multi-list RANDOM(m) cache.
12 *
13 * Templated port of matlab/src/api/cache/cache_miss_rmf.m, including its
14 * nested rmf_drift, rmf_jacobian, rmf_hessian, rmf_noise_matrix,
15 * rmf_fixed_point, rmf_dimension_reduction and rmf_expansion_steady_state.
16 *
17 * The model is a density dependent population process (DDPP) whose state
18 * x(i,k) is the probability that item i sits in list k, k = 0 meaning "not
19 * cached". A request for item i in list k promotes it to list k+1 and demotes
20 * a uniformly chosen occupant of list k+1, so the drift is
21 *
22 * flow(i,k) = p(i) x(i,k) - hit(k) x(i,k+1) / m(k+1),
23 * dx(i,k)/dt -= flow(i,k), dx(i,k+1)/dt += flow(i,k),
24 * hit(k) = sum_j p(j) x(j,k),
25 *
26 * for k = 0..h-1. The mean-field fixed point pi is the t -> infinity limit of
27 * that drift, and Gast's refinement adds the 1/N correction
28 *
29 * E[X] = pi + V/N + O(1/N^2), V = -(F')^-1 (1/2) sum_{b,c} F''_{bc} W_bc,
30 *
31 * with W the solution of the Lyapunov equation F' W + W F'^T + Q = 0 and Q the
32 * noise intensity of the DDPP. Reference: N. Gast, "Expected Values Estimated
33 * via Mean-Field Approximation are 1/N-Accurate", POMACS 2017.
34 *
35 * WHAT UNBLOCKED THIS. The fixed point is reached by integrating the drift to
36 * t = 1e4, which MATLAB does with ode15s. The drift is stiff: the per-item
37 * request rates p(i) of a Zipf-like popularity profile span several orders of
38 * magnitude, so the fast items equilibrate in O(1/p_max) while the slow ones
39 * need O(1/p_min), and an explicit integrator would be pinned to the fast
40 * scale for the whole horizon. The port now has line/util/ode.h, an adaptive
41 * Rosenbrock-4 with an embedded lower-order estimate, and this file uses it.
42 * The Jacobian is supplied analytically (rmf_jacobian is needed for the
43 * refinement anyway), so the integrator never forms a numeric one here.
44 *
45 * DIFFERENCES FROM THE REFERENCE. Three, all in the dimension reduction, and
46 * all forced by the same fact: the Jacobian of this drift is singular by
47 * construction (each item's occupancies are conserved, so the item-indicator
48 * vectors are exact left null vectors) and the refinement divides by its
49 * reduced version.
50 *
51 * 1. The fixed point is polished. The reference stops when ode15s's LOCAL
52 * error estimate meets AbsTol 1e-10, which leaves a residual drift of that
53 * order; this port runs a second integration pass from there at the
54 * tightest tolerances the arithmetic supports, which costs a handful of
55 * steps and leaves a residual of 1e-15. The miss rates move by about 1e-9,
56 * the rank decision below moves from meaningless to unambiguous.
57 * 2. The rank threshold is 1e-8 relative, not MATLAB's rank() threshold of
58 * max(size)*eps*sigma_1. A fixed point located to 1e-10 lifts one of the
59 * exact null directions to a singular value of about 1e-10, which MATLAB's
60 * threshold counts as nonzero; the reduced Jacobian then inherits it and
61 * the 1/N correction comes out orders of magnitude too large. The nonzero
62 * singular values here are O(0.1) and the null ones below 1e-13, so any
63 * threshold in that gap gives the same rank.
64 * 3. The null-space basis comes from LAPACK's SVD at T = double (the same
65 * quantity MATLAB reads out of svd(Fp)) and from a pivoted elimination
66 * plus Gram-Schmidt at any other T. Only the SUBSPACE affects the result:
67 * writing C = [C1; C2], the block C1 Fp D1 that the reduction keeps
68 * depends on C2 only through ker(C2), and the expansion V = D1 V_r
69 * likewise, so any basis of the same subspace gives the same V.
70 *
71 * MEASURED AGREEMENT (MATLAB R2025a, T = double), global miss rate M:
72 *
73 * lambda = [0.5 0.3 0.15 0.05; 0.1 0.2 0.3 0.4] (2 users, 4 items)
74 * m = [2] MATLAB 0.992377021789716 port 0.992377021789764 5e-14
75 * m = [1 2] MATLAB 0.490863272756674 port 0.490863283075726 2.1e-8
76 * lambda = [49 49 49 49 7 1 1]/205 (1 user, 7 items)
77 * m = [1 1 3] MATLAB 0.024017720168154 port 0.024017720912976 3.1e-8
78 * m = [3] MATLAB 0.348362022568998 port 0.345660745093153 7.8e-3
79 *
80 * The last row is not a discrepancy in the arithmetic but in which answer is
81 * returned: on that case the reference's refinement produces non-finite
82 * entries and cache_miss_rmf.m silently keeps the plain mean-field fixed point
83 * (its value agrees with the port's unrefined value to 1e-9), whereas the
84 * port's rank decision leaves the reduced Jacobian non-singular and the 1/N
85 * correction is applied, moving the miss rate by 0.8 percent. The result's
86 * `refined` flag says which of the two happened, so the caller can tell.
87 *
88 * When the reduced Jacobian is singular -- a small or non-hyperbolic fixed
89 * point -- MATLAB's linear solves emit a warning and return non-finite
90 * entries, which cache_miss_rmf.m detects and discards, keeping the plain
91 * mean-field fixed point. The port raises NumericError from the same solves
92 * and catches it in the same place, with the same outcome; the result carries
93 * a `refined` flag saying which of the two was used, which the reference does
94 * not expose.
95 *
96 * ARITHMETIC. Gated: the fixed point is reached by a tolerance-driven
97 * integration, so it is an approximation in any arithmetic.
98 *
99 * COST. The Hessian is a dense model_dim^3 tensor and the Lyapunov solve is a
100 * dense rk^2 by rk^2 system, exactly as in the reference. With n items and h
101 * lists model_dim = n(h+1), so the refinement is practical for tens of items
102 * and quickly stops being so; the plain mean-field path has no such limit.
103 */
104
105#include <cstddef>
106#include <limits>
107#include <type_traits>
108#include <vector>
109
110#include "line/num/number.h"
111#include "line/util/error.h"
112#include "line/util/eig.h"
113#include "line/util/linalg.h"
114#include "line/util/lu.h"
115#include "line/util/matrix.h"
116#include "line/util/lsoda.h"
117#include "line/util/ode.h"
118
119namespace line {
120namespace cache {
121
122/** Flat index of (item i, list k), k = 0 meaning "not cached" (rmf_index.m). */
123inline std::size_t cache_miss_rmf_index(std::size_t i, std::size_t k, std::size_t n_items) {
124 return i + k * n_items;
125}
126
127/** Return value of cache_miss_rmf, mirroring [M,MU,MI,pi0,tout,pi0_t,MU_t,xtraj]. */
128template <class T>
130 T M; ///< global miss rate
131 std::vector<T> MU; ///< (u) per-user miss rate
132 std::vector<T> MI; ///< (n_items) per-item miss rate
133 std::vector<T> pi0; ///< (n_items) per-item miss probability, clipped to [0,1]
134 std::vector<T> xss; ///< the occupancy the metrics were read from
135 bool refined = false; ///< true when the 1/N correction was accepted
136 std::vector<T> tout; ///< transient time grid, empty unless tspan was given
137 Matrix<T> pi0_t; ///< (n_items x nt) transient miss probability
138 Matrix<T> MU_t; ///< (u x nt) transient per-user miss rate
139 Matrix<T> xtraj; ///< (model_dim x nt) transient occupancy
140};
141
142namespace rmf_detail {
143
144/** hit rate of list `level`: sum_i p(i) x(i,level) (rmf_hit_rate.m). */
145template <class T>
146T hit_rate(const std::vector<T>& x, const std::vector<T>& p, std::size_t level,
147 std::size_t n_items) {
148 T hr = num_traits<T>::from_int(0);
149 for (std::size_t i = 0; i < n_items; ++i) hr += p[i] * x[cache_miss_rmf_index(i, level, n_items)];
150 return hr;
151}
152
153/** Mean-field drift F(x) (rmf_drift.m). */
154template <class T>
155std::vector<T> drift(const std::vector<T>& x, const std::vector<T>& p, const std::vector<T>& m,
156 std::size_t n_items, std::size_t h) {
157 const T zero = num_traits<T>::from_int(0);
158 const std::size_t model_dim = n_items * (h + 1);
159 std::vector<T> hr(h + 1, zero);
160 for (std::size_t k = 0; k <= h; ++k) hr[k] = hit_rate(x, p, k, n_items);
161 std::vector<T> dX(model_dim, zero);
162 for (std::size_t i = 0; i < n_items; ++i) {
163 for (std::size_t k = 0; k + 1 <= h; ++k) {
164 const std::size_t ik = cache_miss_rmf_index(i, k, n_items);
165 const std::size_t ik1 = cache_miss_rmf_index(i, k + 1, n_items);
166 const T flow = p[i] * x[ik] - hr[k] * x[ik1] / m[k];
167 dX[ik] -= flow;
168 dX[ik1] += flow;
169 }
170 }
171 return dX;
172}
173
174/**
175 * dF/dx at x (rmf_jacobian.m).
176 *
177 * REFERENCE DEFECT, reproduced deliberately. This is a transcription of
178 * rmf_jacobian in cache_miss_rmf.m, and that function is NOT the derivative of
179 * the rmf_drift it accompanies. Differentiating the drift
180 *
181 * flow(i,k) = p(i) x(i,k) - hit(k) x(i,k+1) / m(k+1), hit(k) = sum_j p(j) x(j,k)
182 *
183 * gives d flow / d x(j,k+1) = -hit(k)/m(k+1) for j = i and ZERO for j != i,
184 * whereas rmf_jacobian adds a further -p(i) x(i,k)/m(k+1) for EVERY j.
185 * Those extra entries belong to the pairwise form of the drift, in which the
186 * demoted item is chosen explicitly, not to the aggregated form that rmf_drift
187 * implements. The discrepancy is real and reproducible: a central difference of
188 * rmf_drift disagrees with rmf_jacobian at those entries by about 10 percent
189 * (see the test, which pins the reference's values rather than the derivative).
190 *
191 * The port keeps the reference's formula because the refined mean-field
192 * correction is DEFINED by it in the reference and changing it would change the
193 * ported answer. It is not used as an integration Jacobian anywhere here: the
194 * fixed point is integrated with a numeric Jacobian, which is what the
195 * reference's own ode15s call does.
196 */
197template <class T>
198Matrix<T> jacobian(const std::vector<T>& x, const std::vector<T>& p, const std::vector<T>& m,
199 std::size_t n_items, std::size_t h) {
200 const T zero = num_traits<T>::from_int(0);
201 const std::size_t model_dim = n_items * (h + 1);
202 std::vector<T> hr(h + 1, zero);
203 for (std::size_t k = 0; k <= h; ++k) hr[k] = hit_rate(x, p, k, n_items);
204 Matrix<T> Fp(model_dim, model_dim, zero);
205 for (std::size_t i = 0; i < n_items; ++i) {
206 for (std::size_t k = 0; k + 1 <= h; ++k) {
207 const std::size_t ik = cache_miss_rmf_index(i, k, n_items);
208 const std::size_t ik1 = cache_miss_rmf_index(i, k + 1, n_items);
209 Fp(ik, ik) -= p[i];
210 Fp(ik1, ik) += p[i];
211 Fp(ik, ik1) += hr[k] / m[k];
212 Fp(ik1, ik1) -= hr[k] / m[k];
213 for (std::size_t j = 0; j < n_items; ++j) {
214 const std::size_t jk = cache_miss_rmf_index(j, k, n_items);
215 const std::size_t jk1 = cache_miss_rmf_index(j, k + 1, n_items);
216 Fp(ik, jk1) -= p[i] * x[ik] / m[k];
217 Fp(ik1, jk1) += p[i] * x[ik] / m[k];
218 Fp(ik, jk) += p[j] * x[ik1] / m[k];
219 Fp(ik1, jk) -= p[j] * x[ik1] / m[k];
220 }
221 }
222 }
223 return Fp;
224}
225
226/**
227 * d^2F/dx^2 (rmf_hessian.m), stored flat: H[(a*model_dim + b)*model_dim + c] is
228 * d^2 F_a / (dx_b dx_c). The drift is quadratic, so the Hessian does not
229 * depend on x, exactly as in the reference.
230 */
231template <class T>
232std::vector<T> hessian(const std::vector<T>& p, const std::vector<T>& m, std::size_t n_items,
233 std::size_t h) {
234 const T zero = num_traits<T>::from_int(0);
235 const std::size_t md = n_items * (h + 1);
236 std::vector<T> H(md * md * md, zero);
237 const auto at = [md](std::size_t a, std::size_t b, std::size_t c) {
238 return (a * md + b) * md + c;
239 };
240 for (std::size_t i = 0; i < n_items; ++i) {
241 for (std::size_t k = 0; k + 1 <= h; ++k) {
242 const std::size_t ik = cache_miss_rmf_index(i, k, n_items);
243 const std::size_t ik1 = cache_miss_rmf_index(i, k + 1, n_items);
244 for (std::size_t j = 0; j < n_items; ++j) {
245 if (j == i) continue;
246 const std::size_t jk = cache_miss_rmf_index(j, k, n_items);
247 const std::size_t jk1 = cache_miss_rmf_index(j, k + 1, n_items);
248 H[at(ik, jk, ik1)] += p[j] / m[k];
249 H[at(ik, ik1, jk)] += p[j] / m[k];
250 H[at(ik, jk1, ik)] -= p[i] / m[k];
251 H[at(ik, ik, jk1)] -= p[i] / m[k];
252 H[at(ik1, jk, ik1)] -= p[j] / m[k];
253 H[at(ik1, ik1, jk)] -= p[j] / m[k];
254 H[at(ik1, jk1, ik)] += p[i] / m[k];
255 H[at(ik1, ik, jk1)] += p[i] / m[k];
256 }
257 }
258 }
259 return H;
260}
261
262/** Noise intensity Q(x) of the DDPP (rmf_noise_matrix.m). */
263template <class T>
264Matrix<T> noise_matrix(const std::vector<T>& x, const std::vector<T>& p, const std::vector<T>& m,
265 std::size_t n_items, std::size_t h) {
266 const T zero = num_traits<T>::from_int(0);
267 const std::size_t md = n_items * (h + 1);
268 Matrix<T> Q(md, md, zero);
269 const int signs[4] = {-1, 1, 1, -1};
270 for (std::size_t i = 0; i < n_items; ++i) {
271 for (std::size_t k = 0; k + 1 <= h; ++k) {
272 for (std::size_t j = 0; j < n_items; ++j) {
273 const T rate = p[i] * x[cache_miss_rmf_index(i, k, n_items)] *
274 x[cache_miss_rmf_index(j, k + 1, n_items)] / m[k];
275 const std::size_t idx[4] = {cache_miss_rmf_index(i, k, n_items),
276 cache_miss_rmf_index(j, k, n_items),
277 cache_miss_rmf_index(i, k + 1, n_items),
278 cache_miss_rmf_index(j, k + 1, n_items)};
279 for (int ia = 0; ia < 4; ++ia)
280 for (int ib = 0; ib < 4; ++ib)
281 Q(idx[ia], idx[ib]) +=
282 rate * num_traits<T>::from_int(signs[ia] * signs[ib]);
283 }
284 }
285 }
286 return Q;
287}
288
289/**
290 * Mean-field fixed point by integrating the drift to tmax (rmf_fixed_point.m).
291 * The reference uses ode15s with RelTol 1e-8 and AbsTol 1e-10 over [0,10000];
292 * the port uses the same horizon and the same tolerances with ode_rosenbrock4
293 * and the analytic Jacobian.
294 */
295template <class T>
296std::vector<T> fixed_point(const std::vector<T>& x0, const std::vector<T>& p,
297 const std::vector<T>& m, std::size_t n_items, std::size_t h,
298 const T& tmax, const T& rtol, const T& atol) {
299 OdeOptions<T> opt;
300 opt.rtol = rtol;
301 opt.atol = atol;
302 opt.store_trajectory = false;
303 const auto f = [&](const T& t, const std::vector<T>& x) {
304 (void)t;
305 return drift(x, p, m, n_items, h);
306 };
307 // numeric-Jacobian rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
308 return ode_rosenbrock4(f, T(num_traits<T>::from_int(0)), tmax, x0, opt).final_state();
309}
310
311/** fixed_point with the reference tolerances, RelTol 1e-8 and AbsTol 1e-10. */
312template <class T>
313std::vector<T> fixed_point(const std::vector<T>& x0, const std::vector<T>& p,
314 const std::vector<T>& m, std::size_t n_items, std::size_t h,
315 const T& tmax) {
316 return fixed_point(x0, p, m, n_items, h, tmax, T(num_traits<T>::from_double(1e-8)),
317 T(num_traits<T>::from_double(1e-10)));
318}
319
320/**
321 * Solve the Lyapunov equation F W + W F^T + Q = 0 by vectorization.
322 *
323 * The vectorized operator is (I (x) F + F (x) I) acting on vec(W); with W
324 * stored row-major, row (a,b) of the system reads
325 * sum_c F(a,c) W(c,b) + sum_c F(b,c) W(a,c) = -Q(a,b).
326 * The system is r^2 by r^2 and is solved with the port's LU. MATLAB calls
327 * lyap(), which uses a Bartels-Stewart Schur factorization; the two compute
328 * the same W, and the Schur route is the faster one, not a different answer.
329 * The vectorized route is used here because it needs nothing beyond the LU
330 * that the port already has, and because it fails loudly (a singular matrix
331 * throws) when F has a zero or a symmetric pair of eigenvalues, which is the
332 * case the caller must detect and reject.
333 */
334template <class T>
335Matrix<T> lyapunov(const Matrix<T>& F, const Matrix<T>& Q) {
336 const std::size_t r = F.rows();
337 const T zero = num_traits<T>::from_int(0);
338 Matrix<T> A(r * r, r * r, zero);
339 std::vector<T> rhs(r * r, zero);
340 for (std::size_t a = 0; a < r; ++a)
341 for (std::size_t b = 0; b < r; ++b) {
342 const std::size_t row = a * r + b;
343 for (std::size_t c = 0; c < r; ++c) {
344 A(row, c * r + b) += F(a, c);
345 A(row, a * r + c) += F(b, c);
346 }
347 rhs[row] = -Q(a, b);
348 }
349 const std::vector<T> w = line::solve(A, rhs);
350 Matrix<T> W(r, r, zero);
351 for (std::size_t a = 0; a < r; ++a)
352 for (std::size_t b = 0; b < r; ++b) W(a, b) = w[a * r + b];
353 return W;
354}
355
356/**
357 * Numeric rank and an orthonormal basis of the LEFT null space of Fp, by
358 * Gaussian elimination with full pivoting on Fp^T followed by Gram-Schmidt.
359 *
360 * MATLAB gets both from svd(Fp): rk = rank(Fp) and the trailing left singular
361 * vectors. The port cannot, because the port's SVD entry point is LAPACK-backed
362 * and double-only while this header is templated; and it does not need to,
363 * because only the SUBSPACE matters, not the basis of it (see the note at the
364 * top of this file). What does matter is the rank DECISION, which is a
365 * tolerance comparison in both codes: MATLAB thresholds the singular values at
366 * max(size)*eps*sigma_max, this thresholds the elimination pivots at
367 * n*eps*max|Fp|. The two agree except on a matrix whose rank is genuinely
368 * ambiguous at that scale, where neither answer is more correct than the other.
369 *
370 * The left null space is where the mean-field conservation laws live: each
371 * item's occupancies sum to one, so the n item-indicator vectors are always in
372 * it, and a symmetric popularity profile (items with equal request rates) adds
373 * more. That is why the rank cannot simply be taken as model_dim - n_items.
374 */
375template <class T>
376struct NullSpace {
377 std::size_t rank = 0;
378 std::vector<std::vector<T>> basis; ///< orthonormal rows spanning {w : w^T Fp = 0}
379};
380
381template <class T>
382NullSpace<T> left_null_space(const Matrix<T>& Fp) {
383 const std::size_t n = Fp.rows();
384 const T zero = num_traits<T>::from_int(0);
385 // Work on M = Fp^T so that its null vectors are the left null vectors of Fp.
386 Matrix<T> M(n, n, zero);
387 T scale = zero;
388 for (std::size_t i = 0; i < n; ++i)
389 for (std::size_t j = 0; j < n; ++j) {
390 M(i, j) = Fp(j, i);
391 const T a = num_abs(Fp(j, i));
392 if (a > scale) scale = a;
393 }
394 if (scale == zero) scale = num_traits<T>::from_int(1);
395 // Rank threshold. NOT the machine-epsilon one: see left_null_space_svd for
396 // why a relative 1e-8 gap is the right question to ask here.
397 const T tol = scale * num_traits<T>::from_double(1e-8);
398
399 std::vector<std::size_t> col_of_pivot;
400 std::vector<std::size_t> perm(n);
401 for (std::size_t i = 0; i < n; ++i) perm[i] = i;
402 std::size_t row = 0;
403 for (std::size_t col = 0; col < n && row < n; ++col) {
404 std::size_t p = row;
405 T best = num_abs(M(row, col));
406 for (std::size_t i = row + 1; i < n; ++i) {
407 const T a = num_abs(M(i, col));
408 if (a > best) {
409 best = a;
410 p = i;
411 }
412 }
413 if (best <= tol) continue; // no pivot in this column: it is free
414 if (p != row)
415 for (std::size_t j = 0; j < n; ++j) std::swap(M(row, j), M(p, j));
416 const T d = M(row, col);
417 for (std::size_t j = 0; j < n; ++j) M(row, j) = M(row, j) / d;
418 for (std::size_t i = 0; i < n; ++i) {
419 if (i == row) continue;
420 const T f = M(i, col);
421 if (f == zero) continue;
422 for (std::size_t j = 0; j < n; ++j) M(i, j) -= f * M(row, j);
423 }
424 col_of_pivot.push_back(col);
425 ++row;
426 }
427
428 NullSpace<T> ns;
429 ns.rank = col_of_pivot.size();
430 std::vector<bool> is_pivot(n, false);
431 for (std::size_t c : col_of_pivot) is_pivot[c] = true;
432 // One basis vector per free column, from the reduced row echelon form.
433 for (std::size_t free_col = 0; free_col < n; ++free_col) {
434 if (is_pivot[free_col]) continue;
435 std::vector<T> v(n, zero);
436 v[free_col] = num_traits<T>::from_int(1);
437 for (std::size_t r = 0; r < col_of_pivot.size(); ++r)
438 v[col_of_pivot[r]] = -M(r, free_col);
439 ns.basis.push_back(v);
440 }
441 // Gram-Schmidt, so that the basis is orthonormal like MATLAB's.
442 using std::sqrt;
443 for (std::size_t i = 0; i < ns.basis.size(); ++i) {
444 for (std::size_t k = 0; k < i; ++k) {
445 T dot = zero;
446 for (std::size_t j = 0; j < n; ++j) dot += ns.basis[i][j] * ns.basis[k][j];
447 for (std::size_t j = 0; j < n; ++j) ns.basis[i][j] -= dot * ns.basis[k][j];
448 }
449 T nrm2 = zero;
450 for (std::size_t j = 0; j < n; ++j) nrm2 += ns.basis[i][j] * ns.basis[i][j];
451 const T nrm = sqrt(nrm2);
452 if (nrm <= tol) throw NumericError("cache_miss_rmf: the null-space basis degenerated");
453 for (std::size_t j = 0; j < n; ++j) ns.basis[i][j] = ns.basis[i][j] / nrm;
454 }
455 return ns;
456}
457
458/**
459 * The same thing from the SVD, which is what the reference uses.
460 *
461 * This matters more than a change of basis normally would. The Jacobian at the
462 * mean-field fixed point is very ill conditioned: on the four-item two-list
463 * case below its singular values are 7.5e-1 ... 1.7e-1, then 5.4e-10, then
464 * five at 1e-17. The 5.4e-10 one is a null direction that the finite accuracy
465 * of the fixed point has lifted off zero, so it counts as a nonzero singular
466 * value under both MATLAB's rank tolerance and any other, and the reduced
467 * Jacobian inherits it. Everything downstream then divides by it, and the 1/N
468 * correction depends on which basis of the (numerically ambiguous) null space
469 * was chosen. Reproducing the reference's numbers therefore requires
470 * reproducing its basis, not merely its subspace. This routine is used when
471 * LAPACK is available and T is double; the templated elimination above is the
472 * fallback, and the two agree whenever the fixed point is hyperbolic and the
473 * rank decision is unambiguous.
474 */
475inline NullSpace<double> left_null_space_svd(const Matrix<double>& Fp) {
476#ifndef LINE_MP_HAVE_LAPACK
477 return left_null_space(Fp);
478#else
479 const std::size_t n = Fp.rows();
480 std::vector<double> a(n * n);
481 for (std::size_t i = 0; i < n; ++i)
482 for (std::size_t j = 0; j < n; ++j) a[j * n + i] = Fp(i, j);
483 const int ni = static_cast<int>(n);
484 std::vector<double> s(n), u(n * n), vt(1);
485 int info = 0, lwork = -1;
486 double wopt = 0.0;
487 const int one = 1;
488 dgesvd_("A", "N", &ni, &ni, a.data(), &ni, s.data(), u.data(), &ni, vt.data(), &one, &wopt,
489 &lwork, &info);
490 if (info != 0) throw NumericError("cache_miss_rmf: LAPACK workspace query failed");
491 lwork = static_cast<int>(wopt);
492 std::vector<double> work(static_cast<std::size_t>(lwork));
493 dgesvd_("A", "N", &ni, &ni, a.data(), &ni, s.data(), u.data(), &ni, vt.data(), &one,
494 work.data(), &lwork, &info);
495 if (info != 0) throw NumericError("cache_miss_rmf: LAPACK dgesvd failed to converge");
496 // rank threshold rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
497 const double tol = 1e-8 * s[0];
498 NullSpace<double> ns;
499 ns.rank = 0;
500 for (double v : s)
501 if (v > tol) ++ns.rank;
502 for (std::size_t c = ns.rank; c < n; ++c) {
503 std::vector<double> row(n, 0.0);
504 for (std::size_t i = 0; i < n; ++i) row[i] = u[c * n + i];
505 ns.basis.push_back(row);
506 }
507 return ns;
508#endif
509}
510
511/** Dispatch: the SVD basis at double, the elimination basis otherwise. */
512template <class T>
513NullSpace<T> left_null_space_for(const Matrix<T>& Fp) {
514 return left_null_space(Fp);
515}
516
517template <>
518inline NullSpace<double> left_null_space_for<double>(const Matrix<double>& Fp) {
519 return left_null_space_svd(Fp);
520}
521
522// ---------------------------------------------------------------------------
523// The general access graph (`accost`).
524// ---------------------------------------------------------------------------
525/**
526 * `rmf_linear_graph`: the standard chain. Row 0 is miss admission (column 0 is
527 * reject, column 1+l admit to list l), row 1+i is a hit in list i, and the top
528 * list self-loops because a hit there moves nothing.
529 */
530template <class T>
531Matrix<T> linear_graph(std::size_t h) {
532 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
533 Matrix<T> g(h + 1, h + 1, zero);
534 g(0, 1) = one;
535 for (std::size_t a = 1; a + 1 <= h; ++a) g(a, a + 1) = one;
536 g(h, h) = one;
537 return g;
538}
539
540/**
541 * `rmf_build_item_graphs`: one (h+1)x(h+1) graph per item, the per-user graphs
542 * averaged by request rate and row-normalized.
543 *
544 * RETURNS EMPTY WHEN THE RESULT IS THE LINEAR CHAIN, and that is not an
545 * optimization. The linear chain is the only case for which the 1/N REFINEMENT
546 * is defined here -- its Jacobian, Hessian and noise matrix are all written for
547 * the chain drift -- so the caller must be able to tell "no graph was declared,
548 * take the refined path" from "a graph was declared, take the plain mean-field
549 * fixed point of the general drift". An empty return is that signal.
550 */
551template <class T>
552std::vector<Matrix<T> > build_item_graphs(const std::vector<std::vector<Matrix<T> > >& accost,
553 const Matrix<T>& lambda, std::size_t n, std::size_t h) {
554 std::vector<Matrix<T> > G;
555 if (accost.empty()) return G;
556 const T zero = num_traits<T>::from_int(0);
557 const Matrix<T> lin = linear_graph<T>(h);
558 const std::size_t u = accost.size();
559 std::vector<Matrix<T> > Gc(n, lin);
560 bool is_linear = true;
561 for (std::size_t k = 0; k < n; ++k) {
562 Matrix<T> num(h + 1, h + 1, zero);
563 T den = zero;
564 for (std::size_t v = 0; v < u; ++v) {
565 if (k >= accost[v].size()) continue;
566 const Matrix<T>& gvk = accost[v][k];
567 if (gvk.rows() == 0) continue;
568 if (gvk.rows() != h + 1 || gvk.cols() != h + 1)
569 throw InputError("cache_miss_rmf: an access graph is not (h+1)x(h+1)");
570 double w = (v < lambda.rows() && k < lambda.cols())
571 ? num_traits<T>::to_double(lambda(v, k))
572 : 0.0;
573 if (!std::isfinite(w)) w = 0.0;
574 const T wv = num_traits<T>::from_double(w);
575 for (std::size_t a = 0; a <= h; ++a)
576 for (std::size_t b = 0; b <= h; ++b) num(a, b) += wv * gvk(a, b);
577 den += wv;
578 }
579 Matrix<T> gk = lin;
580 if (den > zero) {
581 for (std::size_t a = 0; a <= h; ++a)
582 for (std::size_t b = 0; b <= h; ++b) gk(a, b) = num(a, b) / den;
583 } else if (!accost.empty() && k < accost[0].size() && accost[0][k].rows() == h + 1) {
584 gk = accost[0][k];
585 }
586 for (std::size_t a = 0; a <= h; ++a) {
587 T srow = zero;
588 for (std::size_t b = 0; b <= h; ++b) srow += gk(a, b);
589 if (srow > zero)
590 for (std::size_t b = 0; b <= h; ++b) gk(a, b) = gk(a, b) / srow;
591 }
592 Gc[k] = gk;
593 for (std::size_t a = 0; a <= h && is_linear; ++a)
594 for (std::size_t b = 0; b <= h && is_linear; ++b)
595 if (std::fabs(num_traits<T>::to_double(gk(a, b)) -
596 num_traits<T>::to_double(lin(a, b))) >= 1e-9)
597 is_linear = false;
598 }
599 if (!is_linear) G = Gc;
600 return G;
601}
602
603/**
604 * `rmf_drift_graph`: the general RANDOM(m) drift under a per-item access graph.
605 *
606 * A miss is admitted to list i with probability G[k](0,1+i) and a hit in list s
607 * promotes to list i with probability G[k](1+s,1+i); the occupant it displaces
608 * is drawn UNIFORMLY from the target list, which is the RR sample path
609 * (`State.afterEventCache`) and is why every displacement term carries the
610 * 1/m(i) factor. It reduces to the chain drift above when G is the linear
611 * graph, which is what makes `build_item_graphs`'s emptiness test sound.
612 */
613template <class T>
614std::vector<T> drift_graph(const std::vector<T>& x_in, const std::vector<T>& p,
615 const std::vector<Matrix<T> >& G, const std::vector<T>& m,
616 std::size_t n, std::size_t h) {
617 const T zero = num_traits<T>::from_int(0), one = num_traits<T>::from_int(1);
618 const std::size_t model_dim = n * (h + 1);
619 std::vector<T> x = x_in;
620 for (std::size_t a = 0; a < x.size(); ++a) {
621 if (x[a] < zero) x[a] = zero;
622 if (x[a] > one) x[a] = one;
623 }
624 // A(s,i): total rate of insertion or promotion into list i out of list s.
625 Matrix<T> A(h + 1, h + 1, zero);
626 for (std::size_t s = 0; s <= h; ++s)
627 for (std::size_t j = 0; j < n; ++j) {
628 const T xjs = x[cache_miss_rmf_index(j, s, n)];
629 if (xjs == zero) continue;
630 for (std::size_t i = 1; i <= h; ++i) A(s, i) += p[j] * xjs * G[j](s, i);
631 }
632 std::vector<T> dX(model_dim, zero);
633 for (std::size_t k = 0; k < n; ++k) {
634 const T outk = x[cache_miss_rmf_index(k, 0, n)];
635 for (std::size_t i = 1; i <= h; ++i) {
636 const T xki = x[cache_miss_rmf_index(k, i, n)];
637 T infl = p[k] * outk * G[k](0, i);
638 for (std::size_t s = 1; s + 1 <= i; ++s)
639 infl += p[k] * x[cache_miss_rmf_index(k, s, n)] * G[k](s, i);
640 for (std::size_t b = i + 1; b <= h; ++b)
641 infl += A(i, b) * x[cache_miss_rmf_index(k, b, n)] / m[b - 1];
642 T outfl = p[k] * xki * T(one - G[k](i, i));
643 T disp = zero;
644 for (std::size_t s = 0; s + 1 <= i; ++s) disp += A(s, i);
645 outfl += disp * xki / m[i - 1];
646 dX[cache_miss_rmf_index(k, i, n)] += infl - outfl;
647 }
648 T acc = zero;
649 for (std::size_t i = 1; i <= h; ++i) acc += dX[cache_miss_rmf_index(k, i, n)];
650 dX[cache_miss_rmf_index(k, 0, n)] = -acc;
651 }
652 return dX;
653}
654
655/**
656 * `rmf_fixed_point_graph`: the plain mean-field fixed point of the general
657 * drift, at the reference's own horizon of 20000 (twice the chain's, because
658 * the general drift has no refinement to fall back on if it stops short).
659 */
660template <class T>
661std::vector<T> fixed_point_graph(const std::vector<T>& x0, const std::vector<T>& p,
662 const std::vector<Matrix<T> >& G, const std::vector<T>& m,
663 std::size_t n, std::size_t h) {
664 OdeOptions<T> opt;
665 opt.rtol = num_traits<T>::from_double(1e-8);
666 opt.atol = num_traits<T>::from_double(1e-10);
667 opt.store_trajectory = false;
668 const auto f = [&](const T& t, const std::vector<T>& x) {
669 (void)t;
670 return drift_graph(x, p, G, m, n, h);
671 };
672 return ode_rosenbrock4(f, T(num_traits<T>::from_int(0)),
673 T(num_traits<T>::from_int(20000)), x0, opt)
674 .final_state();
675}
676
677} // namespace rmf_detail
678
679/**
680 * Refined mean-field miss rates of a RANDOM(m) multi-list cache.
681 *
682 * @param gamma item access factors. Present for signature compatibility with
683 * cache_miss_rmf.m, which marks it unused and reads
684 * only its size; nothing here depends on it either.
685 * @param m_in (h) list capacities
686 * @param lambda (u x n_items) per-user per-item request rates. The MATLAB
687 * argument is a three-dimensional array and the function reads
688 * only its first page, lambda(v,:,1); this is that page.
689 * @param tmax integration horizon for the fixed point (reference: 1e4)
690 * @param accost per-(user,item) access graph, each an (h+1)x(h+1) matrix; empty
691 * is the linear chain. A NON-LINEAR graph switches the solve to
692 * the general drift AND drops the 1/N refinement, exactly as the
693 * reference does: the refinement's Jacobian, Hessian and noise
694 * matrix are written for the chain drift, so applying it to
695 * another drift would correct the wrong system.
696 */
697template <class T>
698CacheMissRmfResult<T> cache_miss_rmf(const std::vector<T>& gamma, const std::vector<int>& m_in,
699 const Matrix<T>& lambda, const T& tmax,
700 const std::vector<std::vector<Matrix<T> > >& accost) {
702 "cache_miss_rmf requires transcendental arithmetic: its fixed point is reached "
703 "by a tolerance-driven integration of the mean-field drift");
704 (void)gamma;
705 const T zero = num_traits<T>::from_int(0);
706 const T one = num_traits<T>::from_int(1);
707 const std::size_t u = lambda.rows();
708 const std::size_t n_items = lambda.cols();
709 const std::size_t h = m_in.size();
710 if (u == 0 || n_items == 0) throw InputError("cache_miss_rmf: empty request-rate matrix");
711 if (h == 0) throw InputError("cache_miss_rmf: at least one cache list is required");
712
713 std::vector<T> m(h, zero);
714 for (std::size_t k = 0; k < h; ++k) {
715 if (m_in[k] <= 0) throw InputError("cache_miss_rmf: a list has non-positive capacity");
716 m[k] = num_traits<T>::from_int(static_cast<long>(m_in[k]));
717 }
718
719 // non-finite rate rejection rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
720 std::vector<T> lam_i(n_items, zero);
721 T lam_tot = zero;
722 for (std::size_t v = 0; v < u; ++v)
723 for (std::size_t i = 0; i < n_items; ++i) {
724 lam_i[i] += lambda(v, i);
725 lam_tot += lambda(v, i);
726 }
727 if (lam_tot == zero) throw InputError("cache_miss_rmf: all request rates are zero");
728 std::vector<T> p(n_items, zero);
729 for (std::size_t i = 0; i < n_items; ++i) p[i] = lam_i[i] / lam_tot;
730
731 const std::size_t model_dim = n_items * (h + 1);
732
733 // Initial occupancy: the first m(1) items in list 1, the next m(2) in list
734 // 2, and so on; every remaining item outside the cache.
735 std::vector<T> x0(model_dim, zero);
736 std::size_t obj = 0;
737 for (std::size_t k = 1; k <= h; ++k)
738 for (int jj = 0; jj < m_in[k - 1]; ++jj) {
739 ++obj;
740 if (obj <= n_items) x0[cache_miss_rmf_index(obj - 1, k, n_items)] = one;
741 }
742 for (std::size_t i = obj; i < n_items; ++i) x0[cache_miss_rmf_index(i, 0, n_items)] = one;
743
745 const std::vector<Matrix<T> > G = rmf_detail::build_item_graphs(accost, lambda, n_items, h);
746 if (!G.empty()) {
747 // A declared access graph takes the general drift and the plain fixed
748 // point; `refined` stays false, which is the honest report.
749 res.xss = rmf_detail::fixed_point_graph(x0, p, G, m, n_items, h);
750 res.pi0.assign(n_items, zero);
751 for (std::size_t i = 0; i < n_items; ++i) {
752 T v = res.xss[cache_miss_rmf_index(i, 0, n_items)];
753 if (v < zero) v = zero;
754 if (v > one) v = one;
755 res.pi0[i] = v;
756 }
757 res.MI.assign(n_items, zero);
758 res.M = zero;
759 for (std::size_t i = 0; i < n_items; ++i) {
760 res.MI[i] = lam_i[i] * res.pi0[i];
761 res.M += res.MI[i];
762 }
763 res.MU.assign(u, zero);
764 for (std::size_t v = 0; v < u; ++v) {
765 T s = zero;
766 for (std::size_t i = 0; i < n_items; ++i) s += lambda(v, i) * res.pi0[i];
767 res.MU[v] = s;
768 }
769 return res;
770 }
771 // two-pass ODE integration rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
772 std::vector<T> xss = rmf_detail::fixed_point(x0, p, m, n_items, h, tmax);
773 xss = rmf_detail::fixed_point(xss, p, m, n_items, h, tmax,
776
777 // 1/N refinement failure rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
778 try {
779 const Matrix<T> Fp = rmf_detail::jacobian(xss, p, m, n_items, h);
780 const rmf_detail::NullSpace<T> ns = rmf_detail::left_null_space_for(Fp);
781 const std::size_t rk = ns.rank;
782 if (rk == 0 || rk >= model_dim)
783 throw NumericError("cache_miss_rmf: the reduction is degenerate");
784 const std::vector<T> Fpp = rmf_detail::hessian(p, m, n_items, h);
785 const Matrix<T> Q = rmf_detail::noise_matrix(xss, p, m, n_items, h);
786
787 // change-of-basis rationale: see _kb/09-ldes-and-cache.md (cpp port notes)
788 Matrix<T> C(model_dim, model_dim, zero);
789 {
790 std::size_t d = 0;
791 for (std::size_t l = 0; l <= h && d < rk; ++l)
792 for (std::size_t i = 0; i + 1 < n_items && d < rk; ++i, ++d)
793 C(d, cache_miss_rmf_index(i, l, n_items)) = one;
794 if (d != rk) throw NumericError("cache_miss_rmf: the reduction basis is too small");
795 }
796 if (ns.basis.size() != model_dim - rk)
797 throw NumericError("cache_miss_rmf: the null-space basis has the wrong size");
798 for (std::size_t i = 0; i < ns.basis.size(); ++i)
799 for (std::size_t j = 0; j < model_dim; ++j) C(rk + i, j) = ns.basis[i][j];
800 const Matrix<T> Cinv = inverse(C);
801
802 Matrix<T> Fp_r(rk, rk, zero);
803 {
804 const Matrix<T> tmp = matmul(C, matmul(Fp, Cinv));
805 for (std::size_t a = 0; a < rk; ++a)
806 for (std::size_t b = 0; b < rk; ++b) Fp_r(a, b) = tmp(a, b);
807 }
808 Matrix<T> Q_r(rk, rk, zero);
809 {
810 Matrix<T> Ct(model_dim, model_dim, zero);
811 for (std::size_t a = 0; a < model_dim; ++a)
812 for (std::size_t b = 0; b < model_dim; ++b) Ct(a, b) = C(b, a);
813 const Matrix<T> tmp = matmul(C, matmul(Q, Ct));
814 for (std::size_t a = 0; a < rk; ++a)
815 for (std::size_t b = 0; b < rk; ++b) Q_r(a, b) = tmp(a, b);
816 }
817
818 // Reduced Hessian, by the same three contractions the reference does.
819 const auto Hat = [model_dim](std::size_t a, std::size_t b, std::size_t c) {
820 return (a * model_dim + b) * model_dim + c;
821 };
822 std::vector<T> tmp1(rk * model_dim * model_dim, zero);
823 for (std::size_t a = 0; a < rk; ++a)
824 for (std::size_t j = 0; j < model_dim; ++j)
825 for (std::size_t k = 0; k < model_dim; ++k) {
826 T s = zero;
827 for (std::size_t i = 0; i < model_dim; ++i) s += C(a, i) * Fpp[Hat(i, j, k)];
828 tmp1[(a * model_dim + j) * model_dim + k] = s;
829 }
830 std::vector<T> tmp2(rk * rk * model_dim, zero);
831 for (std::size_t a = 0; a < rk; ++a)
832 for (std::size_t b = 0; b < rk; ++b)
833 for (std::size_t k = 0; k < model_dim; ++k) {
834 T s = zero;
835 for (std::size_t j = 0; j < model_dim; ++j)
836 s += tmp1[(a * model_dim + j) * model_dim + k] * Cinv(j, b);
837 tmp2[(a * rk + b) * model_dim + k] = s;
838 }
839 std::vector<T> Fpp_r(rk * rk * rk, zero);
840 for (std::size_t a = 0; a < rk; ++a)
841 for (std::size_t b = 0; b < rk; ++b)
842 for (std::size_t c = 0; c < rk; ++c) {
843 T s = zero;
844 for (std::size_t k = 0; k < model_dim; ++k)
845 s += tmp2[(a * rk + b) * model_dim + k] * Cinv(k, c);
846 Fpp_r[(a * rk + b) * rk + c] = s;
847 }
848
849 const Matrix<T> W_r = rmf_detail::lyapunov(Fp_r, Q_r);
850
851 std::vector<T> C_r(rk, zero);
852 for (std::size_t a = 0; a < rk; ++a) {
853 T s = zero;
854 for (std::size_t b = 0; b < rk; ++b)
855 for (std::size_t c = 0; c < rk; ++c) s += Fpp_r[(a * rk + b) * rk + c] * W_r(b, c);
856 C_r[a] = s;
857 }
858 std::vector<T> rhs(rk, zero);
859 for (std::size_t a = 0; a < rk; ++a)
860 rhs[a] = -C_r[a] / num_traits<T>::from_int(2);
861 const std::vector<T> V_r = line::solve(Fp_r, rhs);
862
863 std::vector<T> xref(model_dim, zero);
864 for (std::size_t i = 0; i < model_dim; ++i) {
865 T s = zero;
866 for (std::size_t a = 0; a < rk; ++a) s += Cinv(i, a) * V_r[a];
867 xref[i] = xss[i] + s / num_traits<T>::from_int(static_cast<long>(n_items));
868 }
869 xss = xref;
870 res.refined = true;
871 } catch (const NumericError&) {
872 // keep the plain mean-field fixed point, as the reference does
873 } catch (const InputError&) {
874 // ditto: a degenerate reduction is not an error of the caller's making
875 }
876
877 res.xss = xss;
878 res.pi0.assign(n_items, zero);
879 for (std::size_t i = 0; i < n_items; ++i) {
880 T v = xss[cache_miss_rmf_index(i, 0, n_items)];
881 if (v < zero) v = zero;
882 if (v > one) v = one;
883 res.pi0[i] = v;
884 }
885 res.MI.assign(n_items, zero);
886 res.M = zero;
887 for (std::size_t i = 0; i < n_items; ++i) {
888 res.MI[i] = lam_i[i] * res.pi0[i];
889 res.M += res.MI[i];
890 }
891 res.MU.assign(u, zero);
892 for (std::size_t v = 0; v < u; ++v) {
893 T s = zero;
894 for (std::size_t i = 0; i < n_items; ++i) s += lambda(v, i) * res.pi0[i];
895 res.MU[v] = s;
896 }
897 return res;
898}
899
900/** cache_miss_rmf on the linear chain, i.e. with no declared access graph. */
901template <class T>
902CacheMissRmfResult<T> cache_miss_rmf(const std::vector<T>& gamma, const std::vector<int>& m_in,
903 const Matrix<T>& lambda, const T& tmax) {
904 return cache_miss_rmf(gamma, m_in, lambda, tmax, std::vector<std::vector<Matrix<T> > >());
905}
906
907/** cache_miss_rmf with the reference horizon tmax = 1e4. */
908template <class T>
909CacheMissRmfResult<T> cache_miss_rmf(const std::vector<T>& gamma, const std::vector<int>& m,
910 const Matrix<T>& lambda) {
911 return cache_miss_rmf(gamma, m, lambda, T(num_traits<T>::from_int(10000)));
912}
913
914/**
915 * Transient mean-field trajectory over [t0,t1] from a given initial occupancy,
916 * the optional TSPAN/X0INIT path of cache_miss_rmf.m. Fills tout, xtraj, pi0_t
917 * and MU_t of the result; the steady-state fields are left at their defaults
918 * because the reference computes them independently of the transient.
919 */
920template <class T>
922 const Matrix<T>& lambda, const T& t0, const T& t1,
923 const std::vector<T>& x0init) {
925 "cache_miss_rmf_transient requires transcendental arithmetic");
926 const T zero = num_traits<T>::from_int(0);
927 const T one = num_traits<T>::from_int(1);
928 const std::size_t u = lambda.rows();
929 const std::size_t n_items = lambda.cols();
930 const std::size_t h = m_in.size();
931 const std::size_t model_dim = n_items * (h + 1);
932 if (x0init.size() != model_dim)
933 throw InputError("cache_miss_rmf_transient: initial occupancy has the wrong length");
934
935 std::vector<T> m(h, zero);
936 for (std::size_t k = 0; k < h; ++k) m[k] = num_traits<T>::from_int(static_cast<long>(m_in[k]));
937
938 std::vector<T> lam_i(n_items, zero);
939 T lam_tot = zero;
940 for (std::size_t v = 0; v < u; ++v)
941 for (std::size_t i = 0; i < n_items; ++i) {
942 lam_i[i] += lambda(v, i);
943 lam_tot += lambda(v, i);
944 }
945 std::vector<T> p(n_items, zero);
946 for (std::size_t i = 0; i < n_items; ++i) p[i] = lam_i[i] / lam_tot;
947
949 opt.rtol = num_traits<T>::from_double(1e-8);
950 opt.atol = num_traits<T>::from_double(1e-10);
951 const auto f = [&](const T& t, const std::vector<T>& x) {
952 (void)t;
953 return rmf_detail::drift(x, p, m, n_items, h);
954 };
955 const OdeSolution<T> s = ode_rosenbrock4(f, t0, t1, x0init, opt);
956
958 const std::size_t nt = s.t.size();
959 res.tout = s.t;
960 res.xtraj = Matrix<T>(model_dim, nt, zero);
961 for (std::size_t j = 0; j < nt; ++j)
962 for (std::size_t i = 0; i < model_dim; ++i) res.xtraj(i, j) = s.y[j][i];
963 res.pi0_t = Matrix<T>(n_items, nt, zero);
964 for (std::size_t i = 0; i < n_items; ++i)
965 for (std::size_t j = 0; j < nt; ++j) {
966 T v = s.y[j][cache_miss_rmf_index(i, 0, n_items)];
967 if (v < zero) v = zero;
968 if (v > one) v = one;
969 res.pi0_t(i, j) = v;
970 }
971 res.MU_t = Matrix<T>(u, nt, zero);
972 for (std::size_t v = 0; v < u; ++v)
973 for (std::size_t j = 0; j < nt; ++j) {
974 T acc = zero;
975 for (std::size_t i = 0; i < n_items; ++i) acc += lambda(v, i) * res.pi0_t(i, j);
976 res.MU_t(v, j) = acc;
977 }
978 res.M = zero;
979 return res;
980}
981
982/** Result of `cache_miss_rmf_expansion_transient`. */
983template <class T>
985 std::vector<T> t; ///< (n_points) output instants, t[0] = 0
986 Matrix<T> X; ///< (n_points x model_dim) mean-field trajectory
987 Matrix<T> V; ///< (n_points x model_dim) 1/N correction trajectory
988 std::vector<Matrix<T> > W; ///< (n_points) covariance, each model_dim x model_dim
989};
990
991/**
992 * Refined mean-field TRANSIENT, `CacheRMF.meanFieldExpansionTransient`.
993 *
994 * The steady-state refinement solves F' V = -(1/2) sum F''_bc W_bc at the fixed
995 * point; the transient one carries the same three objects along the trajectory,
996 * as one coupled system in y = [X (d), V (d), W (d x d)]:
997 *
998 * dX/dt = F(X),
999 * dV/dt = F'(X) V + (1/2) sum_{b,c} F''_{a,b,c} W_{b,c},
1000 * dW/dt = F'(X) W + W F'(X)^T + Q(X),
1001 *
1002 * started from V(0) = 0, W(0) = 0 at the same initial occupancy the fixed point
1003 * uses: the first m(1) items in list 1, the next m(2) in list 2, the rest
1004 * outside. The reported trajectory is X(t) + V(t)/N.
1005 *
1006 * `order = 0` integrates the drift alone and returns V and W identically zero,
1007 * which is the reference's own escape rather than a degenerate case of the
1008 * coupled system.
1009 *
1010 * DOUBLE ONLY, and for the reason the fluid solvers are: the coupled system is
1011 * integrated by LSODA at the reference's own `ode15s` tolerances (RelTol 1e-6,
1012 * AbsTol 1e-10) on the reference's own output grid `linspace(0, time,
1013 * n_points)`, and LSODA's coefficients assume double precision.
1014 *
1015 * THE HESSIAN IS HOISTED OUT OF THE RIGHT-HAND SIDE. The drift is quadratic, so
1016 * F'' does not depend on x -- the reference recomputes it per step, which is
1017 * d^3 work per evaluation for a value that never changes.
1018 */
1019template <class T>
1021 const std::vector<int>& m_in, const Matrix<T>& lambda, const T& time, std::size_t n_points,
1022 int order) {
1023 if (!std::is_same<T, double>::value)
1024 throw UnsupportedError(
1025 "cache_miss_rmf_expansion_transient: the coupled (X,V,W) system is integrated with "
1026 "LSODA, whose coefficients assume double precision; rerun with --arith double");
1027 const T zero = num_traits<T>::from_int(0);
1028 const std::size_t u = lambda.rows();
1029 const std::size_t n_items = lambda.cols();
1030 const std::size_t h = m_in.size();
1031 if (u == 0 || n_items == 0)
1032 throw InputError("cache_miss_rmf_expansion_transient: empty request-rate matrix");
1033 if (h == 0)
1034 throw InputError("cache_miss_rmf_expansion_transient: at least one cache list is required");
1035 if (n_points < 2)
1036 throw InputError("cache_miss_rmf_expansion_transient: at least two output points are "
1037 "required to describe a trajectory");
1038 if (!(num_traits<T>::to_double(time) > 0.0))
1039 throw InputError("cache_miss_rmf_expansion_transient: the horizon must be positive");
1040
1041 std::vector<T> m(h, zero);
1042 for (std::size_t k = 0; k < h; ++k) {
1043 if (m_in[k] <= 0)
1044 throw InputError("cache_miss_rmf_expansion_transient: a list has non-positive capacity");
1045 m[k] = num_traits<T>::from_int(static_cast<long>(m_in[k]));
1046 }
1047
1048 std::vector<T> lam_i(n_items, zero);
1049 T lam_tot = zero;
1050 for (std::size_t v = 0; v < u; ++v)
1051 for (std::size_t i = 0; i < n_items; ++i) {
1052 lam_i[i] += lambda(v, i);
1053 lam_tot += lambda(v, i);
1054 }
1055 if (lam_tot == zero)
1056 throw InputError("cache_miss_rmf_expansion_transient: all request rates are zero");
1057 std::vector<T> p(n_items, zero);
1058 for (std::size_t i = 0; i < n_items; ++i) p[i] = lam_i[i] / lam_tot;
1059
1060 const std::size_t d = n_items * (h + 1);
1061
1062 // The same initial occupancy `cache_miss_rmf` starts its fixed point from.
1063 std::vector<T> x0(d, zero);
1064 {
1065 std::size_t obj = 0;
1066 for (std::size_t k = 1; k <= h; ++k)
1067 for (int jj = 0; jj < m_in[k - 1]; ++jj) {
1068 ++obj;
1069 if (obj <= n_items)
1070 x0[cache_miss_rmf_index(obj - 1, k, n_items)] = num_traits<T>::from_int(1);
1071 }
1072 for (std::size_t i = obj; i < n_items; ++i)
1073 x0[cache_miss_rmf_index(i, 0, n_items)] = num_traits<T>::from_int(1);
1074 }
1075
1076 std::vector<double> grid(n_points, 0.0);
1077 const double tend = num_traits<T>::to_double(time);
1078 for (std::size_t j = 0; j < n_points; ++j)
1079 grid[j] = tend * static_cast<double>(j) / static_cast<double>(n_points - 1);
1080
1081 const std::size_t total = order == 0 ? d : d + d + d * d;
1082 std::vector<double> y0(total, 0.0);
1083 for (std::size_t i = 0; i < d; ++i) y0[i] = num_traits<T>::to_double(x0[i]);
1084
1085 const std::vector<T> Fpp = order == 0 ? std::vector<T>() : rmf_detail::hessian(p, m, n_items, h);
1086
1087 const auto rhs = [&](double t, const double* y, double* dy) {
1088 (void)t;
1089 std::vector<T> x(d, zero);
1090 for (std::size_t i = 0; i < d; ++i) x[i] = num_traits<T>::from_double(y[i]);
1091 const std::vector<T> F = rmf_detail::drift(x, p, m, n_items, h);
1092 for (std::size_t i = 0; i < d; ++i) dy[i] = num_traits<T>::to_double(F[i]);
1093 if (order == 0) return;
1094 const Matrix<T> Fp = rmf_detail::jacobian(x, p, m, n_items, h);
1095 const Matrix<T> Q = rmf_detail::noise_matrix(x, p, m, n_items, h);
1096 for (std::size_t a = 0; a < d; ++a) {
1097 T acc = zero;
1098 for (std::size_t b = 0; b < d; ++b) acc += Fp(a, b) * num_traits<T>::from_double(y[d + b]);
1099 // 0.5 * sum_{b,c} F''_{a,b,c} W_{b,c}
1100 T hcontr = zero;
1101 for (std::size_t b = 0; b < d; ++b)
1102 for (std::size_t c = 0; c < d; ++c) {
1103 const T hv = Fpp[(a * d + b) * d + c];
1104 if (hv == zero) continue;
1105 hcontr += hv * num_traits<T>::from_double(y[2 * d + b * d + c]);
1106 }
1107 dy[d + a] = num_traits<T>::to_double(acc) +
1108 0.5 * num_traits<T>::to_double(hcontr);
1109 }
1110 for (std::size_t a = 0; a < d; ++a)
1111 for (std::size_t b = 0; b < d; ++b) {
1112 T acc = Q(a, b);
1113 for (std::size_t c = 0; c < d; ++c)
1114 acc += Fp(a, c) * num_traits<T>::from_double(y[2 * d + c * d + b]) +
1115 num_traits<T>::from_double(y[2 * d + a * d + c]) * Fp(b, c);
1116 dy[2 * d + a * d + b] = num_traits<T>::to_double(acc);
1117 }
1118 };
1119
1120 LsodaOptions lopt;
1121 lopt.rtol = 1e-6;
1122 lopt.atol = 1e-10;
1123 const LsodaSolution s = lsoda_integrate(rhs, y0, grid, lopt);
1124 if (!s.success || s.y.size() != n_points)
1125 throw NumericError("cache_miss_rmf_expansion_transient: the coupled (X,V,W) system could "
1126 "not be integrated over the requested horizon");
1127
1129 out.t.assign(n_points, zero);
1130 out.X = Matrix<T>(n_points, d, zero);
1131 out.V = Matrix<T>(n_points, d, zero);
1132 out.W.assign(n_points, Matrix<T>(d, d, zero));
1133 for (std::size_t j = 0; j < n_points; ++j) {
1134 out.t[j] = num_traits<T>::from_double(s.t[j]);
1135 for (std::size_t i = 0; i < d; ++i) {
1136 out.X(j, i) = num_traits<T>::from_double(s.y[j][i]);
1137 if (order != 0) out.V(j, i) = num_traits<T>::from_double(s.y[j][d + i]);
1138 }
1139 if (order == 0) continue;
1140 for (std::size_t a = 0; a < d; ++a)
1141 for (std::size_t b = 0; b < d; ++b)
1142 out.W[j](a, b) = num_traits<T>::from_double(s.y[j][2 * d + a * d + b]);
1143 }
1144 return out;
1145}
1146
1147} // namespace cache
1148} // namespace line
1149
1150#endif // LINE_API_CACHE_CACHE_MISS_RMF_H
Malformed or inconsistent input (dimensions, negative populations, ...).
Definition error.h:37
InputError(const std::string &what)
Definition error.h:39
std::size_t cols() const
Definition matrix.h:90
std::size_t rows() const
Definition matrix.h:89
The algorithm cannot proceed on this instance (singular matrix, ...).
Definition error.h:43
NumericError(const std::string &what)
Definition error.h:45
UnsupportedError(const std::string &what)
Definition error.h:51
Eigenvalues and singular values, backed by LAPACK.
The exception types the port throws.
Dense linear algebra over the templated number type: products, identity, inverse, and powers.
LSODA: the LINE-facing wrapper over the vendored solver in third_party/lsoda.hpp.
LU factorization with partial pivoting, templated on the number type.
Dense matrix and non-owning view.
CacheMissRmfResult< T > cache_miss_rmf(const std::vector< T > &gamma, const std::vector< int > &m_in, const Matrix< T > &lambda, const T &tmax, const std::vector< std::vector< Matrix< T > > > &accost)
Refined mean-field miss rates of a RANDOM(m) multi-list cache.
CacheRmfExpansionTransient< T > cache_miss_rmf_expansion_transient(const std::vector< int > &m_in, const Matrix< T > &lambda, const T &time, std::size_t n_points, int order)
Refined mean-field TRANSIENT, CacheRMF.meanFieldExpansionTransient.
CacheMissRmfResult< T > cache_miss_rmf_transient(const std::vector< int > &m_in, const Matrix< T > &lambda, const T &t0, const T &t1, const std::vector< T > &x0init)
Transient mean-field trajectory over [t0,t1] from a given initial occupancy, the optional TSPAN/X0INI...
std::size_t cache_miss_rmf_index(std::size_t i, std::size_t k, std::size_t n_items)
Flat index of (item i, list k), k = 0 meaning "not cached" (rmf_index.m).
double dot(const std::vector< double > &a, const std::vector< double > &b)
The inner product of a row vector with a column held as a vector.
Definition mg1.h:236
T num_abs(const T &v)
Definition number.h:172
OdeSolution< T > ode_rosenbrock4(const F &f, const J &jac, const T &t0, const T &t1, const std::vector< T > &y0, const OdeOptions< T > &opt)
Integrate y' = f(t,y) from t0 to t1 with an analytic Jacobian.
Definition ode.h:304
LsodaSolution lsoda_integrate(const LsodaRhs &f, const std::vector< double > &y0, const std::vector< double > &t_eval, const LsodaOptions &opt=LsodaOptions())
Definition lsoda.h:178
Matrix< T > inverse(const Matrix< T > &A)
Inverse by LU with one factorization and n back substitutions.
Definition linalg.h:72
Matrix< T > matmul(const Matrix< T > &A, const Matrix< T > &B)
Matrix product A B.
Definition linalg.h:36
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
Number-type abstraction for the templated API port.
Adaptive stiff ODE integrator: a four-stage Rosenbrock method of order four with an embedded order-th...
Integration controls.
Definition lsoda.h:64
double atol
absolute tolerance, applied to every component
Definition lsoda.h:66
double rtol
relative tolerance, applied to every component
Definition lsoda.h:65
Result of an integration, mirroring OdeSolution in ode.h.
Definition lsoda.h:126
bool success
false when LSODA returned istate < 0
Definition lsoda.h:132
std::vector< std::vector< double > > y
y[i] is the state at t[i]
Definition lsoda.h:128
std::vector< double > t
output times, t[0] = t_eval[0]
Definition lsoda.h:127
Integration controls.
Definition ode.h:118
Result of an integration.
Definition ode.h:143
std::vector< std::vector< T > > y
y[i] is the state at t[i]
Definition ode.h:145
std::vector< T > t
accepted time points, t[0] = t0
Definition ode.h:144
Return value of cache_miss_rmf, mirroring [M,MU,MI,pi0,tout,pi0_t,MU_t,xtraj].
std::vector< T > pi0
(n_items) per-item miss probability, clipped to [0,1]
Matrix< T > pi0_t
(n_items x nt) transient miss probability
std::vector< T > tout
transient time grid, empty unless tspan was given
Matrix< T > xtraj
(model_dim x nt) transient occupancy
bool refined
true when the 1/N correction was accepted
Matrix< T > MU_t
(u x nt) transient per-user miss rate
std::vector< T > MI
(n_items) per-item miss rate
std::vector< T > xss
the occupancy the metrics were read from
std::vector< T > MU
(u) per-user miss rate
Result of cache_miss_rmf_expansion_transient.
std::vector< Matrix< T > > W
(n_points) covariance, each model_dim x model_dim
Matrix< T > X
(n_points x model_dim) mean-field trajectory
Matrix< T > V
(n_points x model_dim) 1/N correction trajectory
std::vector< T > t
(n_points) output instants, t[0] = 0