前言
需要多大的緩存才能維持特定的命中率?
Cache Hit Rate 緩存命中率指的是某處內存被緩存數據後被訪問到(命中)的概率,即一段時間內命中數與訪問數的比例。
簡介PID
【反饋】這個專欄現在是把《企業級編程與控制理論》中的一些知識寫出來,其實就是模擬系統、建模、信號與系統相關知識加上Python實現不同案列的模擬系統的知識。一個反饋系統不能避免被討論的就是PID了,即比例積分微分三相調節器。這裏再簡單介紹下我現在所理解的PID。
爲了方便理解,做一個簡圖。藍色的線是設定的目標輸出值,紅色的是系統實際的輸出值,二者之差是error,其乘以取樣間隔就是此點附近的矩形面積——兩條線之間的矩形。兩個取樣點間的error變化率就是上式中的微分項。u是實際系統輸入。假設系統爲y=nx,那麼u就是x,y是實際輸出,error=r-y。
因爲不知道系統內部的n是多少,所以只能在外面瞎猜!假設這真的就是個線性系統,那麼只要輸入正確的x,就一定能得到想要的y。如果是曲線的方程的話,取一小段也可以當做線性。怕的就是如果輸入同樣一個數得到不同的輸出,那就麻煩點了,這種情況可以考慮概率,某個輸入使輸出的最有可能滿足目標就行!!!
當實際輸出滿足設定的目標時,error=0,則比例項爲零,但是隻有一個取樣值滿足還不能確定輸出就穩定了,可能只是兩條曲線交叉了一下而已!所以需要再進行取樣,兩次取樣間的error=0變化率如果爲零,說明輸出穩定了。但是!!!僅僅這樣還是不行,輸出穩定不一定就是輸出和目標一樣了——可能兩條線平行了!那你也會問,不是說好了目標和輸出一樣了嗎???答案是式子中的比例項不是有個係數嗎!如果比例係數比較小,那麼細小的差別就會被忽略,即接近零,如果比例係數太大,輸入又太大。
然後我們看積分項,積分對應的就是兩條曲線之間的面積,如果面積不變了,那麼也能說明error=0,此時積分項爲某個特定的常數,這個常數輸入系統就有可能得到目標值!但是!!!此時仍然沒有解決上面這個問題,可能兩條曲線平行了。當兩條曲線平行時,error值太小以至於需要好一會兒才能在輸出端感覺到這種差異性。所以此時需要加上反應快一些的比例項……
- 介紹到這裏並沒有得出PID就能滿足所有系統反饋的情況!都是大概滿足要求,並且上面我所寫的式子到文章後面還有優化!
模擬控制系統框架
製作一個feedback包。
#__init__.py
__name__="feedback package with PID and some filter"
__author__="應急食品派蒙"
#全局變量
__all__=["deltaT","yForPlot"]
#離散系統取樣間隔,默認1s
deltaT=1
#記錄實際輸出命中率的變量,用來繪圖
yForPlot=[]
#Component.py
'''
組件的基類
'''
class Component:
#封裝了組件的動態參數,每個時間幀調用一次這個函數
def work(self,arr):
return arr
#返回一組表示組件內部狀態的任意字符串
def monitoring(self):
return ""
#PID_Controller.py
import feedback
import feedback.Component
'''
PID係數
kp比例係數
ki積分系數
kd微分系數
i 積分項
d 微分項
prevError 前一時間幀的偏移
'''
class PID_Controller(feedback.Component.Component):
def __init__(self,kp,ki,kd=0):
self.kp,self.ki,self.kd=kp,ki,kp
self.i=0
self.d=0
self.prevError=0
def work(self,error):
self.i+=feedback.deltaT*error
self.d=(error-self.prevError)/feedback.deltaT
self.prevError=error
return self.kp*error +self.ki*self.i + self.kd*self.d
#ADV_Controller.py
import feedback
import feedback.Component
'''
ADV_Controller提供了比PID_Controller更高級的實現
多了兩個功能:一個是防止積分器飽和的“限值”,防止積分器飽和。
如果控制器輸出超過構造函數所指定的限制,積分項在下一時間幀
中不會更新。限值這一任務由控制器下面的“執行器”來決定
另一個是爲微分計算的過濾器。
默認情況下,不會對微分部分做平滑處理,但是如果給平滑參數
傳遞小於一的正數時,將調用簡單的遞歸過濾器(單一指數
平滑),以平滑微分部分的貢獻。
PID係數
kp比例係數
ki積分系數
kd微分系數
i 積分項
d 微分項
prevError 前一時間幀的偏移
clamped 是否限值
clamp_low 限值的低值
clamp_high 限值的高值
ratio 當前微分佔之後微分的比率
'''
class ADV_Controller(feedback.Component.Component):
#默認PI調節,默認沒有微分過濾器
def __init__(self, kp, ki, kd=0,clampLimit=(-1e10,1e10),ratio=1):
self.kp, self.ki, self.kd = kp, ki, kp
self.i = 0
self.d = 0
self.prevError = 0
self.clamped=True
self.clamp_low,self.clamp_high=clampLimit
self.ratio=ratio
def clamp(self,arr):
return self.clamp_low<arr<self.clamp_high
def work(self, error):
#如果沒有限值(clamped=False),積分項就保持不變
if self.clamped:
self.i+=feedback.deltaT*error
#微分項除了此時間幀的微分外,還增加了之前的微分項
self.d=(self.ratio*(error-self.prevError)/feedback.deltaT + (1.0-self.ratio)*self.d )
self.prevError=error
u=self.kp*error + self.ki*self.i + self.kd*self.d
self.clamped=ADV_Controller.clamp(self,u)
return u
#Integrator.py
import feedback
import feedback.Component
'''
計算輸入的積分,需要將累積項乘以每個時間幀的間隔
data 存放所有arr的和
'''
class Integrator(feedback.Component.Component):
def __init__(self):self.data=0
def work(self,arr):
self.data+=arr
return feedback.deltaT*self.data
#FixedFilter.py
import feedback.Component
'''
平滑過濾器FixedFilter計算最近n次輸入的未加權的平均值
n 輸入的未加權的數的個數
data 存放所有arr的和
'''
class FixedFilter(feedback.Component.Component):
def __init__(self,n):
self.n=n
self.data=[]
def work(self,arr):
self.data.append(arr)
if len(self.data)>self.n:
self.data.pop(0) #移除列表data的第一個元素
return float(sum(self.data))/len(self.data)
#RecurFilter.py
import feedback.Component
'''
平滑過濾器RecurFilter用簡單的指數平滑算法實現
S(t)=α * X(t) + (1 - α) * S(t-1)
ratio 當前值佔輸出值的比例
y 輸出值
'''
class RecurFilter(feedback.Component.Component):
def __init__(self,ratio):
self.ratio=ratio
self.y=0
#混合當前值與原始值
def work(self,arr):
self.y=self.ratio * arr +(1- self.ratio)*self.y
return self.y
#Plant.py
import feedback.Component
'''
工廠類,也是組件之一。在模擬中作爲系統的基類存在!
'''
class Plant(feedback.Component.Component):
pass
#Loop.py
import feedback
import feedback.Component
'''
GetTarget 傳遞的目標值函數
controller 傳遞的控制器,例如PID_Controller的實例
plant 傳遞的模擬系統
tm 系統經歷的時間,以deltaT爲單位(默認爲秒)。
inverted 是否反轉error的公式。反轉爲error=y-r,本來error=r-y。
trainer 傳遞的執行器。執行器放在控制器和系統之間!
filter 傳遞的過濾器。過濾器放在反饋迴路中,即把系統輸出值y過濾後得到y2,用這個y2來算error!
'''
def ClosedLoop(GetTarget,controller,plant,
tm=500,inverted=False,
trainer=feedback.Component.Component(),
filter=feedback.Component.Component()):
print("==============模擬開始==================")
#系統輸出值經過濾器後的值
y2=0
for t in range(tm):
r=GetTarget(t)
error=r-y2
if inverted : error = - error
#得到控制器的輸出值u
u=controller.work(error)
#得到執行器輸出值v,即系統實際輸入值
v=trainer.work(u)
#得到系統輸出值y
y=plant.work(v)
#記錄輸出值
feedback.yForPlot.append(y)
#過濾輸出值
y2=filter.work(y)
#打印看看!!!
#print("遍歷:",t,",時間:",t*feedback.deltaT,",目標:",r,",輸出:",y,"\n",
# "中間變量:u=",u,",v=",v,",y2=",y2,"[",plant.monitoring(),"]\n")
print("==============模擬結束==================")
'''
這個函數內設置我們需要的目標值,交給用戶自己設置
'''
def GetTarget(arr):
pass
'''
plant 模擬系統
traversal 輸入的遍歷變量,是一個列表
tm 遍歷最大值
'''
def OpenedLoop(plant,traversal,tm=500):
print("==============模擬開始==================")
for t in range(tm):
#得到系統輸出值
y=plant.work(traversal[t])
#記錄輸出值
feedback.yForPlot.append(y)
print("==============模擬結束==================")
# #測試樣式
# p=Plant()
# c=PID_Controller(0.5,0.05)
# ClosedLoop(GetTarget,c,p)
案例:高速緩存命中率
這個案例會介紹一種經典的反饋原理應用,它使用調整高速緩存容量的方法來改進高速緩存的命中率。
受控系統是一個高速緩存器,例如Web緩存器或者數據庫緩存器。假設高速緩存器容量爲n,使用“最近訪問協議”的高速緩存器。
“最近訪問協議”
如果用戶請求的條目已經在高速緩存器中找到,就將其返回給請求者,如果沒有找到就從後臺存儲器中獲取,然後返回給請求者,同時將此內容添加到高速緩存器中。如果高速緩存器中的條目超過n,那麼就把最老的條目刪除掉。
要求就是70%的請求應從高速緩存器中獲取,並且高速緩存器應儘可能小,以便最大程度地減少內存消耗,併爲其他任務騰出空間!
定義組件
“請求”設在某個概率上浮動的數A,想訪問的條目在某個概率上的數B,因爲實際系統千差萬別,這裏可以隨便設個A與B的關係,比如沒有關係!
緩存的設計就簡單了,只需要用一個字典表示高速緩存器就行,後臺緩存器可以省略。因爲後臺緩存器的使用是在沒命中的情況下,去後臺緩存中查找條目,再返回給用戶,並且保留一份在高速緩存中——在這個過程中,後臺查找的步驟可以省略,返回給用戶也可以省略,我們只需要命中率就行!所以後臺緩存器也就可以省略了。
因爲這裏討論的只是如何用反饋思維解決控制的問題,不考慮性能,所以能省就省。
系統輸入爲緩存大小,輸出爲命中率!這樣就可以通過緩存大小來影響命中率了!!!
如何判斷條目的新舊?
用遍歷的變量就可以,當某個條目被訪問到,即在字典中被查找到後,就更新該條目的值爲變量的變量。
當條目被訪問到就返回1,未被訪問到就返回0,通過取多次的平均值,記爲該次訪問的命中率。
#main.py
import feedback as fb
import feedback.Plant
import feedback.FixedFilter
import feedback.RecurFilter
import feedback.PID_Controller
import feedback.ADV_Controller
import feedback.Loop
import random
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.axisartist.axislines import SubplotZero
'''
緩存器
這個系統傳入u,傳出0/1,經過過濾後會得到一個小數,這個小數就是命中率
t 遍歷變量,可作爲時間。
可以當做條目新舊的判據,當訪問到某條目時就更新其數據爲t
size 條目數量
cache 緩存。這個字典的鍵是條目,值是遍歷變量t
SendDemand 傳遞的要求函數
'''
#記錄緩存器大小的變量,用於作圖
yCacheSize=[]
class Cache(fb.Plant.Plant):
def __init__(self,size,SendDemand):
self.t=0
self.size=size
self.cache={}
self.SendDemand=SendDemand
def work(self,u):
self.t+=1
#size爲非零整數
self.size = max(0,int(u))
#記錄緩存器大小
yCacheSize.append(self.size)
#取出對應的條目,這裏是個小的不確定的模擬系統,就用一個概率代替了
#item是個隨機數,模擬請求到的條目/數據
item = self.SendDemand(self.t)
if item in self.cache:
self.cache[item]=self.t #更新條目,表示此條目最近訪問過
return 1
#刪除舊的條目
cacheLen=len(self.cache)
if cacheLen>= self.size:
#要被刪除的數量
num = cacheLen - self.size + 1
tmp={}
for k in self.cache.keys():
tmp[self.cache[k]]=k
for t in sorted(tmp.keys()):
#刪除最老的元素
del self.cache[tmp[t]]
num -= 1
if num == 0:break
#不在緩存中的條目添加進緩存,這裏忽略了從後臺緩存中查找。
self.cache[item]=self.t
return 0
'''
高速緩存器
n 使用FixedFilter過濾器時,平均計算間隔
ratio 使用RecurFilter過濾器時,當前傳入數佔之後數的比例
因爲要對傳出的0/1進行求和平均,所以只能使用FixedFilter這個過濾器
f 過濾器
'''
class SmoothCache(Cache):
def __init__(self,size,demand,n):
super().__init__(size,demand)
self.f = fb.FixedFilter.FixedFilter(n)
# def __init__(self, size, demand, ratio):
# super().__init__(size, demand)
# self.f = fb.RecurFilter.RecurFilter(ratio)
#arr是輸入值,先經過一個緩存系統,得到的輸出值再經過一個過濾器輸出
def work(self,arr):
#print("系統輸入>",arr,end="")
y=super().work(arr)
yy=self.f.work(y)
#print("系統輸出>",yy)
return yy
def monitoring(self):
return "高速緩存器"
'''
目標函數,返回需要的曲線。這裏是命中率
目標曲線如下
t=[0, 2500, 2500, 4500, 4500, 7800, 7800, 10000]
y=[0.7,0.7, 0.9,0 .9, 0.15,0.15 ,0.5, 0.5]
'''
def GetTarget(arr):
if arr<2500:return 0.7
if arr<4500:return 0.9
if arr<7800:return 0.15
return 0.5
'''
需求函數
要是分三個不同的概率那就特別不準了。。。
高斯分佈:以 mu 爲均值,sigma 爲標準差
random.gauss(mu, sigma)
'''
def SendDemand(arr):
# if arr<3000:return int(random.gauss(0,15))
# elif arr<5000:return int(random.gauss(0,35))
# else:return int(random.gauss(100,15))
return int(random.gauss(0,15))
'''
工具作圖
'''
def showInPlot():
# 實際的輸出數量的曲線
xS = np.arange(0, 10000, 1)
yS = fb.yForPlot
# 目標曲線
xTarget = [0, 2500, 2500, 4500, 4500, 7800, 7800, 10000]
yTarget = [0.7, 0.7, 0.9, 0.9, 0.15, 0.15, 0.5, 0.5]
#第一個子圖
plt.subplot(2,1,1)
#實際命中率(系統輸出)
plt.plot(xS, yS, color="g", linestyle="-", linewidth=1.0, label="real rate")
#目標命中率
plt.plot(xTarget, yTarget, color="b", linestyle="-", linewidth=1.0, label="targeted rate")
plt.xlabel("traversal variable is t")
plt.ylabel("cache hit rate")
plt.legend(loc="upper right", bbox_to_anchor=(0.9, 0.95))
#第二個子圖
plt.subplot(2, 1,2)
#緩存器大小(系統輸入)
plt.plot(xS, yCacheSize, color="m", linestyle="-", linewidth=1.0, label="cache size")
plt.xlabel("traversal variable is t")
plt.ylabel("cache size")
plt.legend(loc="upper right", bbox_to_anchor=(0.9, 0.95))
plt.show()
if __name__=="__main__":
#
# #傳遞條目數量爲零、需求函數、過濾器的間隔或之前值佔之後值比例
p=SmoothCache(0,SendDemand,150) #每10個平均計算一次命中率
#傳遞比例項係數、積分項係數、微分項係數
#c=fb.PID_Controller.PID_Controller(1.5,1.3)
c=fb.ADV_Controller.ADV_Controller(1.5,1.0,1.01,ratio=0.9)
fb.Loop.ClosedLoop(GetTarget,c,p,10000)
#作圖
showInPlot()
# print(fb.__name__,fb.__author__,fb.__all__,fb.deltaT)
# if 'deltaT' in globals().keys(): print('yes')
# else:print('no')
#
# print(globals().keys())
具有控制器的系統輸出和控制系統輸出的效果
沒有控制器的系統輸出的效果
#不進行控制,顯示本來的面貌
p = SmoothCache(0, SendDemand, 150)
t=np.arange(0, 200, 0.02)
fb.Loop.OpenedLoop(p,t,10000)
高速緩存器越大,命中率越大,但是容量超過100後命中率基本沒有變化。一百以內容量與命中率的關係是線性的。
挺奇怪的結論!100這個數也太小了,實際容量都遠遠高於這個數,等於就是說高速緩存器容量和命中率沒什麼關係(誤)。
其實原因是請求太少了,請求的種類多少影響緩存器的大小。
#重寫SendDemand
return int(random.gauss(300,115))
#更改遍歷的參數
t=np.arange(0, 900, 0.09)
在增加了請求種類後,相同的緩存大小下命中率就下降了。
結論
當請求種類大小不變時,增加緩存器大小可以增加命中率。但是超過某個臨界點,命中率就與緩存器大小無關了。
參考:《企業級編程與控制理論》