1. 程式人生 > 其它 >《阿里雲第三屆資料庫效能挑戰賽》分享

《阿里雲第三屆資料庫效能挑戰賽》分享

一、前言

賽題官網: 阿里雲第三屆資料庫大賽 - 效能挑戰賽

今年的資料庫比賽可謂異常激烈,原定 2021年07月02日 ~ 2021年08月06日 的複賽,因為主辦方原因被延期至 2021-08-20,而前排的分數相差都在秒、半秒、甚至毫秒級,“卷”的程度可見一斑

一般這種限定Java語言的比賽,鄙人都是會義無反顧參與的,在享受比賽的期間,更可以提高自身技術,何樂而不為呢?國際慣例,先報下本次比賽成績哈

賽段 排名
預熱塞 3
第一賽季 2
第二賽季 6
決賽答辯 季軍

季軍的獎勵是人民幣1萬塊錢,決賽答辯環節也是激烈異常,文末附上決賽期間的一些圖片

二、賽制介紹

具體賽制規則大家可檢視官網介紹

此處我簡單描述下大規則

  • 第一賽季 2021年5月17日 ~ 2021年6月30日(含預熱賽)
  • 第二賽季 2021年7月02日 ~ 2021年8月20日

5月17日至8月20日,比賽歷經3個月,可謂曠日持久

三、賽題介紹

語言限定

  • Java
  • 只能使用 JDK 8 標準庫

賽題本身描述比較簡潔,簡言之就是給你一堆數,排序後返回第K大值

3.1、第一賽季(初賽)

  • 選手需要設計實現 quantile 分析函式,匯入指定的資料,並回答若干次 quantile 查詢
  • 實現 load 和 quantile 介面。load 介面會先被呼叫,負責載入測試資料(將提供選手一塊高效能磁碟儲存處理好的資料);quantile 介面在 load 後呼叫,負責處理查詢
  • 可用資源 4核 4G
  • 測試資料:只有一張表 lineitem,只有兩列 L_ORDERKEY (bigint], L_PARTKEY (bigint),資料量 3億行
  • 初賽會單執行緒查詢 10 次
  • 查詢結果正確的前提下,耗時越低排名越高

格式類似於:

3.2、第二賽季(複賽)

複賽在初賽的基礎上增加持久化和高併發要求

  • 可用資源 8核 8G
  • 測試資料:多張表,多列,資料量 10億行
  • 複賽會用多個執行緒併發查詢若干次
  • 複賽查詢會分兩輪,先併發查詢一輪,然後kill掉程序,然後重啟,再併發查詢一輪
  • 查詢結果正確的前提下,耗時越低排名越高

說明:因複賽是初賽的升級版,所以後續論述主要針對複賽展開,其中會摻雜一些初賽的歷程

四、解題

4.1、大思路

我們最終是需要將資料排序,並返回K大值的,但面對的原始檔達74G,即便全部資料都用位元組儲存,也有30G之多,而評測機的記憶體只有8G,明顯不可能將全部資料放入記憶體後再排序。那為了解決此問題,比較容易想到的一個點便是:多part快排,整體歸併的思路

4.1.1、區域性快排、整體多路歸併

假定我們啟動8個執行緒,每個執行緒一次讀取4M的資料,那程式完全可以將4M的資料進行快排後落盤,等全部資料讀取完畢後,我們便積累了多個但有序的資料塊,然後再將這些資料塊進行多執行緒歸併排序

當資料全部有序後,莫說查詢4000次,即便是查詢4000萬次,查詢模組的效能也能達到最優;但此方案的劣勢也相當明顯,全量排序需要消耗大量的cpu,最終的瓶頸很有可能由IO轉移到cpu排序上,經過小資料量的benchmark,該方案很快被摒棄

4.1.2、分桶

既然資料量巨大,我們為什麼不採用分桶排序呢?將全量資料拆分成N個桶,每個桶內的資料都可以被直接載入至記憶體,一次性排序完畢(目標資料是30G,假定我們分1024桶的話,每個桶的資料量僅有30M左右);當所有分桶資料都排序完成,那全量資料自然也是全量有序的了。但某個分桶內的資料只有將全量資料讀取完畢後,才能確定,所以我們必須要經歷:讀->分桶(不排序)->落盤->讀取分桶全量資料->排序->落盤排序後資料

選擇分桶方案後,我們發現方案的實操性變得可控了,但上述方案同時也存在明顯的不足:那就是頻繁的IO。資料被讀取、寫入、再讀取、再寫入。

4.1.3、分桶2.0

我們仔細分析一下便發現,雖然步驟繁瑣,但是貌似每一步都必不可少:

  • 讀取 如果不讀取完整資料,就無法確定每個分桶內的資料集
  • 寫入 如果不將分桶內的資料進行無序落盤,那8G的記憶體根本儲存不了30G的目標資料
  • 再讀取 如果不排序,我們在查詢的時候,會無從得知該具體返回哪條記錄
  • 再寫入 最終的30G有序資料也一定要落盤

反覆思考幾次後,便發現第二階段僅僅會查詢4000次,而4000次的查詢有可能不會命中所有分桶,但我們卻興師動眾的將全量資料進行了全排序。例如假定我們將每一列都分成2048個桶,這樣全部4列的資料,就會被分割為2048*4=8192個分桶,而在第二階段查詢的時候,也僅僅會查詢4000次,這就意味著即便4000次查詢每一次都命中不同的分桶,那麼也至少有一半兒多的分桶沒有命中。

那最後可得出結論:排序不是必須的,那什麼時候排序呢?我們可以在查詢階段再對具體命中的分桶排序,何樂而不為呢?這樣便可大大提高程式效能

那分桶的方案便可簡化為:

那如何確定目標資料落在哪個分桶呢?其實我們只要保證桶之間有序即可;假如我們分了4個桶,每個桶資料及範圍如下:

  • bucket 0 儲存1-100範圍的資料,總共數量有20個
  • bucket 1 儲存101-200範圍的資料,總共數量有50個
  • bucket 2 儲存201-300範圍的資料,總共數量有35個
  • bucket 3 儲存301-400範圍的資料,總共數量有38個

當尋找排序為100大的資料時,其一定是落在bucket 2號分桶內,如下圖所示:

總結:之所以最終鎖定分桶且不排序的方案,是因為查詢的次數太少了,為了僅僅4000次的查詢,而進行全量資料的排序的方案價效比實在太低。不過我們可以思考一個問題,如果第二階段不是查詢4000次,而是查詢4000萬次呢?如果真是如此的話,那麼我相信全量排序一定會定位成:“磨刀不誤砍柴工”了

4.2、流程分析

大思路定了以後,我們再來分析下賽題。複賽給出了2個介面:

  • load
  • quantile

其實選手本質上就是實現這2個介面,把介面邏輯填充完整即可,介面的協議內容如下

public interface AnalyticDB {

    void load(String tpchDataFileDir, String workspaceDir) throws Exception;

    String quantile(String table, String column, double percentile) throws Exception;
}

程序會被評測程式啟動2次:

  • 一、程序第一次啟動,首先呼叫load介面,接下來呼叫10次quantile介面
  • 二、整個程序被 kill 掉
  • 三、程序第二次啟動,首先呼叫load介面,接下來呼叫4000次quantile介面

複賽給出了2張源表的資料,每張表均為2列,每列的資料行數是10億行,因為所有資料均為long型別,這樣總共存在40億個long值,在Java中,使用8個位元組儲存長整型,所以我們簡單做個算式便可得出,40億個long大約會佔用40億*8/1024/1024/1024 30G 的空間。但原始檔是以字元儲存的,即一個十進位制的位佔用一個位元組,一個long如果是19位的話,就會佔用19個位元組,因long的值為隨機生成,故原始檔大小約 74G

在我們讀取資料後,需要對資料進行排序等cpu操作,因記憶體只有8G,且程序會被kill,所以解析出來的30G資料一定需要落盤,至此我們可以描繪一下整個程序的執行軌跡

我們簡單把所有行為分為4個步驟:

  • load_1 載入原始檔74G的資料,解析處理
  • query_1 10次查詢
  • load_2 載入關鍵部分資料
  • query_2 4000次查詢

由於load_2只會載入一些關鍵資料,且query_1只會進行10次查詢,基數較小;所以耗時操作分佈在load_1query_2

4.3、讀

IO讀取貌似沒有什麼可展開說的,注意一些關鍵點即可:

注意點 說明
1、基準測試 在評測機上做IO的benchmark test,探測到啟動多少執行緒、單次寫入量多大時能打滿IO
2、檔案讀取方式 FileChannel vs MappedByteBuffer(mapp) 兩者的效能做下對比,雖然通常情況下,mapp只在單次寫入小資料量時才有優勢,但有時不同的評測機表現得差異很大,所以針對性的比較一下還是很有必要
3、堆外記憶體 因為JVM對於IO操作的特殊處理,在對檔案進行讀、寫時,無論採用的是DirectByteBuffer還是HeapByteBuffer,JVM均會將資料首先拷貝至堆外記憶體中,所以無形中,使用HeapByteBuffer會多一次資料拷貝(其實還是JAVA垃圾回收帶來的問題,在垃圾回收時,堆記憶體的資料地址會發生移動並重排,而native方法接收的是address以及寫入大小,address變動會帶來致命的問題,但總不能設定在IO操作時,不能進行GC吧。又因為堆外記憶體不受GC約束,所以設計者將資料主動拷貝一份至堆外,來避免垃圾回收帶來的尷尬;在java doc中也標註使用者儘量使用堆外記憶體以提高效能,此處不再贅述
4、序列讀取 此處較好理解,即便是多執行緒讀取IO,我們也要控制請求是序列訪問的。因為不論是機械磁碟還是SSD,其本身的併發讀寫能力是非常低的,所以當我們多執行緒讀取某個檔案時,一定要控制讀取姿勢,保證序列讀取的同時,充分利用好作業系統的 Page Cache

對於第4點,簡單展開說一下。我們看以下程式碼:

場景一:

@Test
public void test() throws Exception {
    int threadNum = 8;
    int readSize = 1024 * 1024;
    FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
    AtomicInteger readFlag = new AtomicInteger();
    Thread[] threads = new Thread[threadNum];
    for (int i = 0; i < threadNum; i++) {
        threads[i] = new Thread(() -> {
            try {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(readSize);
                while (true) {
                    int blockIndex = readFlag.getAndIncrement();
                    int flag = fileChannel.read(byteBuffer, blockIndex * readSize);
                    if (flag == -1) {
                        break;
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
    for (Thread thread : threads) {
        thread.start();
    }
    for (Thread thread : threads) {
        thread.join();
    }
}

場景二:

@Test
public void test2() throws Exception {
    int threadNum = 8;
    int readSize = 1024 * 1024;
    FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
    AtomicInteger readFlag = new AtomicInteger();
    Thread[] threads = new Thread[threadNum];
    for (int i = 0; i < threadNum; i++) {
        threads[i] = new Thread(() -> {
            try {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(readSize);
                while (true) {
                    synchronized (Object.class) {
                        int blockIndex = readFlag.getAndIncrement();
                        int flag = fileChannel.read(byteBuffer, blockIndex * readSize);
                        if (flag == -1) {
                            break;
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
    for (Thread thread : threads) {
        thread.start();
    }
    for (Thread thread : threads) {
        thread.join();
    }
}

場景二對比場景一,僅僅是在讀取資料時,添加了synchronized鎖,感覺效能沒有場景一高。但由於作業系統的pageCache存在,場景二其實是順序讀的模式,所以真實測下來的話,場景二的效能肯定要高於場景一的

然而,正是在這個眾所周知的點上,栽了一個大跟頭。。。。

評測機採用的 intel 的持久記憶體儲存介質PMEM,使得這塊盤具備了併發能力,也就是說,在本次評測機上,場景一的效能是要高於場景二的。下面附上 PMEM 的官網,有興趣的同學可以去了解下,本文不再展開

Intel傲騰持久化記憶體介紹

4.4、提取long

說明:此處的解析主要是指將字元儲存的10進位制資料,轉換為位元組儲存

這個命題感覺很小,沒什麼值得聊得,但是不得不說,小細節中藏著大文章。

4.4.1、轉換位元組儲存

為什麼要轉換為位元組儲存? 其實目的主要是為了儲存壓縮。假定現在有一個數據,內容是123,字元在儲存的時候,不關心這個資料是什麼型別,且只認為這是一組字元陣列組成,因為目標值中,只存在0-9,10個數字,所以儲存123的話,只需要3個位元組

那這不是好事兒嗎?如果123儲存在一個long型別時,需要8個byte,而現在儲存123僅需要3個位元組。如果僅拿123舉例的話,的確是這樣,但比賽的資料都是隨機生成,且資料雜湊,絕大多數的資料都是19位,這樣的話,儲存一個long就需要19個位元組,遠大於8個位元組

源資料舉例

2747223341331115405,4799778556018601156
3277655512998525145,5145305521134065229
5014057769282191800,1358990770775079655
6180255258051820430,9182333839965782307

4.4.2、如何轉換

方式一

比較容易想到的方式便是利用jdk進行字串分割

String[] split = str.split(",");
long data1 = Long.parseLong(split[0]);
long data2 = Long.parseLong(split[1]);

當然這種看起來就慢的方式實在是太慢了split()parseLong()內部都是大量的計算以及各類校驗,如果你真的採用這種方式解析字元的話,那可能估計得有一半兒以上的時間浪費在了這裡

方式二

其實經典的將十進位制轉換二進位制的方式便是乘10法

  • 1 如果只有1位,那麼直接將其返回
  • 12 可通過1*10+2得到
  • 123 層層計算 (1*10+2)*10+3得到
  • 1234 層層計算 ((1*10+2)*10+3)*10+4得到
  • ... 以此類推

由此不難寫出如下程式碼

for (int i = 0; i < length; i++) {
    byte element = unsafe.getByte(addressTmp++);
    if (element < 45) {
        storeData(data);
        data = 0L;
    } else {
        data = data * 10 + (element - 48);
    }
}

方式三

方式二已經很快了,難道有更快的策略嗎?答案是肯定的。我們看一下方式二存在的弊端,那就是每個位元組都要執行if (element < 45)的判斷,假定每個long為19位,40億long的話需要進行判斷的次數為40億*19次,而在cpu優化中,if是比較耗時的。通常我們採用分支預判或者減少分支的方式,那上述邏輯如何減少分支判斷呢?

我們發現一點,大多數的數字長度均為19位,比例幾乎佔到 90%,且最小的數字長度也 >= 11位,所以我們可以直接判斷當前位置後的第20位是否為分隔符,如果是的話,那麼就可以肯定這段range中的資料均為0-9,這樣便可以不用分支判斷

while (endAddress > addressTmp) {
    byte element = unsafe.getByte(addressTmp + 19);
    long tmp = 0;
    if (element < 45) {
        for (int j = 0; j < 19; j++) {
            tmp = tmp * 10 + (unsafe.getByte(addressTmp++) & 15);
        }
        addressTmp++;
        storeData(element, tmp);
    } else {
        for (int j = 0; j < 19; j++) {
            byte ele = unsafe.getByte(addressTmp++);
            if (ele < 45) {
                storeData(ele, tmp);
                break;
            } else {
                tmp = tmp * 10 + (ele & 15);
            }
        }
    }
}

另外乘10法依舊還有優化的空間,例如123,如果採用data = data * 10 + (element - 48)來計算,自然無可厚非,但如果我們已經知曉1231已處於百位的位置、2處於十位、3處於個位,那麼可以直接執行1*100 + 2*10 + 3的運算,這樣效能會有半秒的提升

方式四

方式四是比賽結束後才找到資料,特此說明哈

因為方式一至方式三,都是面向位元組的,如果我們能面向long操作,直接將一個“十進位制”的long轉換為二進位制的,那效能不可同日而語。其主要思想是將一個long拆成2個,long1保留奇數位的位元組,long2保留偶數位的位元組,然後執行 long2*10 + (long1>>8)

以下是本人根據論文實現的 long 值轉換

@Test
public void test() {
    String str = "1234567890123456789";
    byte[] bytes = str.getBytes();
    ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder());

    long long1 = byteBuffer.getLong();
    long1 = transLong(long1);
    long long2 = byteBuffer.getLong();
    long2 = transLong(long2);
    long byte1 = byteBuffer.get();
    byte1 &= 0x0f;
    long byte2 = byteBuffer.get();
    byte2 &= 0x0f;
    long byte3 = byteBuffer.get();
    byte3 &= 0x0f;

    long tail = byte1 * 100 + byte2 * 10 + byte3;
    long res_0 = long1 * 100000000000L + long2 * 1000L + tail;
    System.out.println(res_0);
}

private long transLong(long half) {
    long upper = (half & 0x000f000f000f000fL) * 10;
    long lower = (half & 0x0f000f000f000f00L) >> 8;
    half = lower + upper;

    upper = (half & 0x000000ff000000ffL) * 100;
    lower = (half & 0x00ff000000ff0000L) >> 16;
    half = lower + upper;

    upper = (half & 0x000000000000ffffL) * 10000;
    lower = (half & 0x0000ffff00000000L) >> 32;

    return lower + upper;
}

有興趣的同學可以翻閱原文連線:Faster Integer Parsing 本文不再展開

4.5、分桶

分桶思想是整個賽題的大脈絡,策略好壞直接影響最終成績

4.5.1、如何分桶

如何進行分桶呢?我們可以利用資料雜湊的特點,假定現在所有的資料範圍是[1,1000],因為要保證桶自身是有序的,所以可以將資料分為10個桶:[1,100]、[101,200]、[201,300]、[301,400]......、[901,1000],這樣就可保證第一個分桶的資料都是小於第二個分桶的,第二個分桶的資料都是小於第三個分桶的。。。

不過我們分桶時除了滿足桶有序外,還要對二進位制友好,最好通過簡單的位移操作便可獲取分桶號,所以自然我們便想到通過擷取高位元組的bit來確定分桶個數

這樣帶來的好處是,直接執行data >> shift便可以獲取到分桶下標

4.5.2、分幾個桶

既然分桶的話,就存在一個重要命題:分多少個桶合適?我們知道最終耗時是load階段跟query階段的加和。

  • 如果分桶數量少了,那麼load階段耗時變小,因為邏輯需要處理的分流變得簡單。最極端的情況是整個程式只分一個桶,那分桶階段的耗時將會變得忽略不計。但分桶數量變少必然導致每個分桶內的資料增多,那麼查詢階段的耗時也會驟增
  • 如果分桶數量多了,其結果正好相反,即load階段的耗時增加、query階段的耗時減少

所以我們需要在分桶數量上尋找平衡點,此消彼長的模型一定存在一箇中軸值,或者說是拋物線的最高點,來保證load階段與query階段的加和最小,也就是整體耗時最少。經過大量的benchmark,我的方案最終得出的結論是:2048個桶。在2048桶時,load階段的耗時為28s左右,4000次的query階段的耗時為3.3s左右

4.5.3、二次(多次)分桶

如果選手一上來就將總的分桶數量設定為2048,並開始進行分發、cpu計算等,那最終的成績一定上不來:簡單設想一下,40億個long值,每一次分發都面對是一個長度是2048的陣列或二維陣列或陣列引用,每進行一次資料分發,就加大了cpu各級快取失效的機率,從而拖慢效能。那該如何解決此問題呢?

答案就是二次分發,或者多次分發。我們可以將一個2048長度的陣列拆分為128個大分桶,每個大分桶內再拆分16個小分桶。128*16=2048,這樣資料每次分流時,面對的是128或16長度的陣列,大大增加cpu cache的命中率。本人親測,這塊能提升10s左右的效能

那多次分桶的數量是不是越多越好呢?例如我進行11次分發,這樣每一次分發的陣列長度可能只有2,豈不是更能提高cpu cache的命中率了嗎?但多級分桶並不是比賽的銀彈,它帶來最直觀的問題就是資料拷貝,如果真的進行了11次多級分桶,那30G的資料將會在記憶體中進行11次的拷貝,這個帶來的後果是災難性的,同4.5.1論述的場景類似,多級分桶跟耗時也是一個拋物線的模型,具體進行幾次分桶就需要選手摸爬滾打的benchmark

4.6、尋找K大值

load階段結束後就要進行4000次的桶內查詢了

4.6.1 排序

最直觀的方式當然是排序了,因為目標資料量比較大,分了2048個桶後,每個桶內的資料也有大約50萬,所以直接進行快排,並返回第80位的資料arr[79]即可

此處額外提一下JDK自帶的Arrays.sort()排序方法,此方法是直接進行快排的嗎?答案是否定的,該方法真實的邏輯為:

但是如果想利用Arrays.sort()進行歸併排序的話,需要注意的是,歸併排序需要用到額外的陣列來存放臨時排序結果,而直接呼叫Arrays.sort()會每次都新建陣列,拖慢效能,所以真有此類需求的話需留意,根據場景可將輔助陣列繫結至執行緒上下文中

4.6.2 尋找K大值

我們冷靜下來再來梳理一遍此處的需求,我們真實的需求只是想找到某個陣列中的K大值,而排序的方案則是興師動眾的將全量資料都進行了一遍排序。

而尋找K大值的過程其實與快排類似,例如我們尋找第80大值,然後找到了中軸值50,最終發現,中軸值左邊有70個值,右邊有30個,那第80大值一定在中軸值的右側,所以我們再針對右邊的30個數重複這個操作,而左邊的70個值,可以直接放棄,不用再迭代排序,貼一下程式碼:

/**
 * 尋找K大值
 * @param nums	目標陣列
 * @param l	左index
 * @param r	右index
 * @param k	K大
 * @return	具體值
 */
public static long solve(long[] nums, int l, int r, int k) {
    if (l == r) {
        return nums[l];
    }
    int p = partition(nums, l, r);

    if (k == p) {
        return nums[p];
    } else if (k < p) {
        return solve(nums, l, p - 1, k);
    } else {
        return solve(nums, p + 1, r, k);
    }
}

private static int partition(long[] arr, int l, int r) {
    long v = arr[l];
    int j = l;
    for (int i = l + 1; i <= r; i++) {
        if (arr[i] < v) {
            j++;
            swap(arr, j, i);
        }
    }
    swap(arr, l, j);
    return j;
}

private static void swap(long[] arr, int a, int b) {
    long temp = arr[a];
    arr[a] = arr[b];
    arr[b] = temp;
}

4.6.3 再快一點

尋找K大值的方案已經很快了,難道還有更快的方案?

是的,此處就要利用原題意描述的資料特徵了:隨機生成、離散。為了敘述方便,我們簡化一下模型:隨機給定100個數,這些資料的範圍是[1-100],然後需要返回第80大的資料。這樣的話我們能否對目標資料進行預測?返回第80大的資料,且資料分佈是[1-100],那可以預測目標值就是80,可80大概率不是正確答案,但一定是在80左右浮動,此時我們可以設定一個浮動百分比,或者上下浮動的範圍,例如:[75-85],所以我們接下來的工作就是尋找目標陣列落在這個區域以及小於這個區域的數量了,假定統計的結果如下:

所以我們可以非常確定目標資料一定落在[75-85]區間,這樣只掃描一遍資料後,便將資料縮小到了很小的範圍。有同學可能會問,如果掃描一遍後沒有命中範圍怎麼辦?那就重新執行尋找K大值的方法,保證程式不出錯,而至於浮動範圍設定為多少合適,就又是拋物線模型,尋找最高點了

4.7、寫

寫入操作沒有太多值得分享的點,保證堆外記憶體寫入、以及單次寫入量不宜過小都是一些基本注意事項

值得一提的是,有小夥伴建議寫入使用write(ByteBuffer[] srcs)的方式,其底層呼叫函式做了很多優化,我方案修改成此方式後,效能並沒有有效提升,有興趣的同學可以深入探索下

五、執行緒模型

針對於程序內的“讀-解析(cpu)-寫”場景,此處提出兩個執行緒模型

  • 1、讀、cpu、寫放在同一個執行緒中,通過增加執行緒來提高整體效能。這樣做的好處是減少執行緒互動的開銷,降低內耗,適用於大多數的場景
  • 2、在程序內,將不同的操作交由不同的執行緒池分別處理,雖然可能增加執行緒互動的內耗,不過在特定的場景下對提高效能可以起到正向優化

兩個執行緒模型各有優劣,很難明確地說哪種模型更好,不同的場景表現差異較大,所以本人的結論還是那句亙古不變的話:Benchmark Everything

六、其他優化

6.1、壓縮

隨機生成的離散資料如何壓縮呢?其實倒也不難想到,因為我們已經將資料前N個bit提取出來作為分桶編號了,所以這N個bit都是重複資料,例如想壓縮掉高位的8個bit(1個位元組)的話,可以有2種方式:

writeBuffer.putLong(index, data);
index += 7;

或者

writeBuffer.put((byte) ((data1 << 8 >>> 56)));
writeBuffer.putInt((int) (data1 << 16 >>> 32));
writeBuffer.putShort((short) (data1));

6.2、優化開闢空間

6.2.1 堆記憶體

有時候陣列開闢空間的耗時也將會是一個很大的提升點,可以通過多執行緒併發開闢空間的方式,來提供效能。為什麼陣列開闢這麼耗時?不就是申請一段連續的記憶體空間嗎?其實本身申請記憶體空間不耗時,但記憶體申請完畢後,會對陣列內全部資料有個賦0操作,而這個操作本身是相當耗時的

6.2.2 堆外記憶體(直接記憶體)

當我們想申請堆外記憶體DirectByteBuffer時,發現速度也相當慢,翻看其原始碼便能發覺其本身開闢空間時,同樣存在賦0操作

long base = 0;
try {
    base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
    Bits.unreserveMemory(size, cap);
    throw x;
}
// 賦0操作
unsafe.setMemory(base, size, (byte) 0);

那如何繞過這個蹩腳操作呢?同樣翻看原始碼便可發現,其控制寫入、讀取是通過兩個關鍵變數addresscapacity:一個是當前buffer的記憶體地址,一個是buffer的長度,我們是否可以通過反射瞞天過海呢?以下貼上原始碼

private static Field addr;
private static Field capacity;

static {
    try {
        addr = Buffer.class.getDeclaredField("address");
        addr.setAccessible(true);
        capacity = Buffer.class.getDeclaredField("capacity");
        capacity.setAccessible(true);
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    }
}

public static ByteBuffer newFastByteBuffer(int cap) {
    long address = unsafe.allocateMemory(cap);
    ByteBuffer bb = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder());
    try {
        addr.setLong(bb, address);
        capacity.setInt(bb, cap);
    } catch (IllegalAccessException e) {
        return null;
    }
    bb.clear();
    return bb;
}

6.3 滾動讀

多執行緒如何讀取檔案呢?我們獲取到檔案大小後,完全可以為每個執行緒指定讀取區間,但這樣可能會造成木桶效應,即程式的耗時取決於最慢執行緒的耗時,同時可能帶來評測程式的不穩定

理想的情況是滾動讀,多個執行緒一起來消費資料;但如果一次讀取的資料量不夠大,可能執行滾動的cas操作會消耗較多的cpu,簡單直接的解決方式是一次標記一段資料,減少執行緒見的爭搶

private int tmpBlockIndex = -1;

private int threadReadData() throws Exception {
    if (tmpBlockIndex == -1) {
        tmpBlockIndex = number.getAndAdd(cpuThreadNum);
    } else {
        if ((tmpBlockIndex + 1) % cpuThreadNum == 0) {
            tmpBlockIndex = number.getAndAdd(cpuThreadNum);
        } else {
            tmpBlockIndex++;
        }
    }

    int indexNum = tmpBlockIndex;
}

還有很多小的cpu優化不能窮舉,有興趣同學可以參看原始碼

七、致謝

首先給本次adb比賽點個大大的贊,不論是初賽還是複賽,本次比賽沒有修改過題目描述、沒有私自換過評測資料、排行榜沒有清空、更沒有給選手留下漏洞,是我近幾年參賽中最乾淨、純粹的賽題了;其他賽道或將來的比賽應該向人家學習

其次整個比賽期間,真心感謝身邊小夥伴@振興、@滿倉、@新然、@笳鑫的鼎力協助 [抱拳]

原始碼地址: [email protected]:xijiu/tianchi-2021-db-contest.git