藉助友盟+U-APM實現終端卡頓優化的全記錄

效能優化實踐者發表於2021-11-08

目前手機SOC的效能越來越少,很多程式設計師在終端程式的開發過程中也不太注意效能方面的優化,尤其是不注意對齊和分支優化,但是這兩種問題一旦出現所引發的問題,是非常非常隱蔽難查的,不過好在專案中用到了移動端的效能排查神器友盟U-APM工具的支援下,最終幾個問題得到了圓滿解決。

我們先來看對齊的問題,對齊在沒有併發競爭的情況下不會有什麼問題,編譯器一般都會幫助程式設計師按照CPU字長進行對齊,但這在終端多執行緒同時工作的情況下可能會隱藏著巨大的效能問題,在多執行緒併發的情況下,即使沒有共享變數,也可能會造成偽共享,由於具體的程式碼涉密,因此我們來看以下抽象後的程式碼。
public class Main {

public static void main(String[] args) {
    final MyData data = new MyData();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();
try{
Thread.sleep(100);
} catch (InterruptedException e){
e.printStackTrace();
}

long[][] arr=data.Getitem();
System.out.println("arr0 is "+arr[0]+"arr1 is"+arr[1]);

}

}
class MyData {
private long[] arr={0,0};

public long[] Getitem(){
return arr;
}

public void add(int j){
for (;true;){
arr[j]++;
}
}
}

在這段程式碼中,兩個子執行緒執行類似任務,分別操作arr陣列當中的兩個成員,由於兩個子執行緒的操作物件分別是arr[0]和arr[1]並不存在交叉的問題,因此當時判斷判斷不會造成併發競爭問題,也沒有加synchronized關鍵字。

但是這段程式卻經常莫名的卡頓,後來經過多方的查詢,並最終通過友盟的卡頓分析功能我們最終定位到了上述程式碼段,發現這是一個由於沒有按照快取行進行對齊而產生的問題,這裡先將修改完成後的虛擬碼向大家說明一下:

public class Main {

public static void main(String[] args) {
    final MyData data = new MyData();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();

new Thread(new Runnable() {
public void run() {
data.add(0);
}
}).start();
try{
Thread.sleep(10);
} catch (InterruptedException e){
e.printStackTrace();
}

long[][] arr=data.Getitem();
System.out.println("arr0 is "+arr0+"arr1 is"+arr1);

}

}
class MyData {
private long[][] arr={{0,0,0,0,0,0,0,0,0},{0,0}};

public long[][] Getitem(){
return arr;
}

public void add(int j){
for (;true;){
arrj++;
}
}
}

可以看到整體程式沒有作何變化,只是將原來的陣列變成了二維陣列,其中除了第一個陣列中除arr0元素外,其餘arr0-a0元素除完全不起作何與程式執行有關的作用,但就這麼一個小小的改動,卻帶來了效能有了接近20%的大幅提升,如果併發更多的話提升幅度還會更加明顯。

快取行對齊排查分析過程

首先我們把之前程式碼的多執行緒改為單執行緒序列執行,結果發現效率與原始的程式碼一併沒有差很多,這就讓我基本確定了這是一個由偽共享引發的問題,但是我初始程式碼中並沒有變數共享的問題,所以這基本可以判斷是由於對齊惹的禍。
現代的CPU一般都不是按位進行記憶體訪問,而是按照字長來訪問記憶體,當CPU從記憶體或者磁碟中將讀變數載入到暫存器時,每次操作的最小單位一般是取決於CPU的字長。比如8位字是1位元組,那麼至少由記憶體載入1位元組也就是8位長的資料,再比如32位CPU每次就至少載入4位元組資料, 64位系統8位元組以此類推。那麼以8位機為例我們們來看一下這個問題。假如變數1是個bool型別的變數,它佔用1位空間,而變數2為byte型別佔用8位空間,假如程式目前要訪問變數2那麼,第一次讀取CPU會從開始的0x00位置讀取8位,也就是將bool型的變數1與byte型變數2的高7位全部讀入記憶體,但是byte變數的最低位卻沒有被讀進來,還需要第二次的讀取才能把完整的變數2讀入。

也就是說變數的儲存應該按照CPU的字長進行對齊,當訪問的變數長度不足CPU字長的整數倍時,需要對變數的長度進行補齊。這樣才能提升CPU與記憶體間的訪問效率,避免額外的記憶體讀取操作。但在對齊方面絕大多數編譯器都做得很好,在預設情況下,C編譯器為每一個變數或是資料單元按其自然對界條件分配空間邊界。也可以通過pragma pack(n)呼叫來改變預設的對界條件指令,呼叫後C編譯器將按照pack(n)中指定的n來進行n個位元組的對齊,這其實也對應著組合語言中的.align。那麼為什麼還會有偽共享的對齊問題呢?

現代CPU中除了按字長對齊還需要按照快取行對齊才能避免併發環境的競爭,目前主流ARM核移動SOC的快取行大小是64byte,因為每個CPU都配備了自己獨享的一級快取記憶體,一級快取記憶體基本是暫存器的速度,每次記憶體訪問CPU除了將要訪問的記憶體地址讀取之外,還會將前後處於64byte的資料一同讀取到快取記憶體中,而如果兩個變數被放在了同一個快取行,那麼即使不同CPU核心在分別操作這兩個獨立變數,而在實際場景中CPU核心實際也是在操作同一快取行,這也是造成這個效能問題的原因。

Switch的坑

但是處理了這個對齊的問題之後,我們的程式雖然在絕大多數情況下的效能都不錯,但是還是會有卡頓的情況,結果發現這是一個由於Switch分支引發的問題。
switch是一種我們在java、c等語言程式設計時經常用到的分支處理結構,主要的作用就是判斷變數的取值並將程式程式碼送入不同的分支,這種設計在當時的環境下非常的精妙,但是在當前最新的移動SOC環境下執行,卻會帶來很多意想不到的坑。
出於涉與之前密的原因一樣,真實的程式碼不能公開,我們先來看以下這段程式碼:
public class Main {

public static void main(String[] args) {
    long now=System.currentTimeMillis(); 

int max=100,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is "+a+"b is "+b+"c is "+c);

}

}

其中隨機數其實是一個rpc遠端呼叫的返回,但是這段程式碼總是莫名其妙的卡頓,為了復現這個卡頓,定位到這個程式碼段也是通過友盟U-APM的卡頓分析找到的,想復現這個卡頓只需要我們再稍微把max範圍由調整為5。
public class Main {

public static void main(String[] args) {
    long now=System.currentTimeMillis(); 

int max=5,min=0;
long a=0;
long b=0;
long c=0;
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
switch(ran){
case 0:
a++;
break;
case 1:
a++;
break;
default:
c++;
}
}
long diff=System.currentTimeMillis()-now;
System.out.println("a is "+a+"b is "+b+"c is "+c);

}

}

那麼執行時間就會有30%的下降,不過從我們分析的情況來看,程式碼一平均每個隨機數有97%的概念要行2次判斷才能跳轉到最終的分支,總體的判斷語句執行期望為20.97+10.03約等於2,而程式碼二有30%的概念只需要1次判斷就可以跳轉到最終分支,總體的判斷執行期望也就是0.31+0.62=1.5,但是程式碼二卻反比程式碼一還慢30%。也就是說在程式碼邏輯完全沒變只是返回值範圍的概率密度做一下調整,就會使程式的執行效率大大下降,要解釋這個問題要從指令流水線說起。

指令流水線原理

我們知道CPU的每個動作都需要用晶體震盪而觸發,以加法ADD指令為例,想完成這個執行指令需要取指、譯碼、取運算元、執行以及取操作結果等若干步驟,而每個步驟都需要一次晶體震盪才能推進,因此在流水線技術出現之前執行一條指令至少需要5到6次晶體震盪週期才能完成

為了縮短指令執行的晶體震盪週期,晶片設計人員參考了工廠流水線機制的提出了指令流水線的想法,由於取指、譯碼這些模組其實在晶片內部都是獨立的,完成可以在同一時刻併發執行,那麼只要將多條指令的不同步驟放在同一時刻執行,比如指令1取指,指令2譯碼,指令3取運算元等等,就可以大幅提高CPU執行效率:

以上圖流水線為例 ,在T5時刻之前指令流水線以每週期一條的速度不斷建立,在T5時代以後每個震盪週期,都可以有一條指令取結果,平均每條指令就只需要一個震盪週期就可以完成。這種流水線設計也就大幅提升了CPU的運算速度。
但是CPU流水線高度依賴指指令預測技術,假如在流水線上指令5本是不該執行的,但卻在T6時刻已經拿到指令1的結果時才發現這個預測失敗,那麼指令5在流水線上將會化為無效的氣泡,如果指令6到8全部和指令5有強關聯而一併失效的話,那麼整個流水線都需要重新建立。

所以可以看出例子當中的這個效率差完全是CPU指令預測造成的,也就是說CPU自帶的機制就是會對於執行概比較高的分支給出更多的預測傾斜。
處理建議-用雜湊表替代switch
我們上文也介紹過雜湊表也就是字典,可以快速將鍵值key轉化為值value,從某種程度上講可以替換switch的作用,按照第一段程式碼的邏輯,用雜湊表重寫的方案如下:
import java.util.HashMap;
public class Main {

public static void main(String[] args) {
    long now=System.currentTimeMillis(); 

int max=6,min=0;
HashMap<Integer,Integer> hMap = new HashMap<Integer,Integer>();
hMap.put(0,0);
hMap.put(1,0);
hMap.put(2,0);
hMap.put(3,0);
hMap.put(4,0);
hMap.put(5,0);
for(int j=0;j<10000000;j++){
int ran=(int)(Math.random()*(max-min)+min);
int value = hMap.get(ran)+1;

hMap.replace(ran,value);
}

long diff=System.currentTimeMillis()-now;
System.out.println(hMap);
System.out.println("time is "+ diff);

}

}

上述這段用雜湊表的程式碼雖然不如程式碼一速度快,但是總體非常穩定,即使出現程式碼二的情況也比較平穩。

經驗總結

一、有併發的終端程式設計一定要注意按照快取行(64byte)對齊,不按照快取行對齊的程式碼就是每增加一個執行緒效能會損失20%。
二、重點關注switch、if-else分支的問題,一旦條件分支的取值條件有所變化,那麼應該首選用雜湊表結構,對於條件分支進行優化。
三、選擇一款好用的效能監測工具,如:友盟U-APM,不僅免費且捕獲型別較為全面,推薦大家使用。

作者:馬佔傑

相關文章