1. 程式人生 > >四國軍棋引擎開發(11)多執行緒搜尋

四國軍棋引擎開發(11)多執行緒搜尋

由於現在沒有什麼好的辦法優化剪枝來增加搜尋深度,所以現在通過不同的方法進行搜尋,最後綜合各種搜尋方法的結果選擇最佳著法。每一種搜尋方法是獨立的,所以單獨放在一個執行緒裡搜尋,如果CPU是多核的,作業系統會自動把每個執行緒放在不同的核心上搜索,達到了平行計算的效果。當前更新版本是2.2,大的框架基本差不多了,行棋時還有很多bug,所以勝率不是很理想,測試結果如下

 

 

1.多執行緒框架

首先在主函式裡新建執行緒,這裡強調一點是必須在主函式裡(即main函式裡)新建執行緒才能在多核上執行,如果在子執行緒裡再新建執行緒,該執行緒其實是和子執行緒執行在同一個核上。

pthread_t CreatSearchThread(Junqi* pJunqi)
{
    pthread_t tidp;

    pthread_create(&tidp,NULL,(void*)search_thread,pJunqi);
    return tidp;
}

新建的執行緒會一直在那裡執行,不會銷燬。如果沒有搜尋任務時,執行緒會一直在那裡等待訊息,此時是不佔用CPU資源的。在收到訊息後,執行緒被喚醒,根據訊息的內容進行某種型別的搜尋。棋盤的內容和搜尋快取分別在pJunqi和pEngine這2個物件裡,在搜尋前需要把這2個物件拷貝過來,棋盤的鄰接點pJunqi->aBoard[][].pAdjList,棋盤點位pJunqi->ChessPos和棋子pJunqi->Lineup裡的內容都是通過指標相互關聯的,所以需要在新建的物件中對這些指標內容重新初始化。相關程式碼如下

void *search_thread(void *arg)
{
    ......

    while(1)
    {

        //等待接收訊息
        len = mq_receive(pJunqiBase->search_qid, (char *)aBuf, REC_LEN, NULL);
        (void)len;//不用
        pMsg = (SearchMsg*)aBuf;

        //拷貝物件
        ......
        ChessBoardCopy(pJunqi);//初始化物件中的指標內容
       
        ......
        pJunqi->eSearchType = pMsg->type;//獲取訊息中的搜尋型別


       //接下去開始搜尋
       ......
     }
}

在主搜尋執行緒engine_thread裡開啟多執行緒搜尋,設定搜尋型別併發送訊息。這裡要注意的開始搜尋前,分執行緒要拷貝主執行緒的物件,這時主執行緒不能開始搜尋,需要等分執行緒拷貝完畢才能開始搜尋。主執行緒拷貝完畢後不能立即結束搜尋,還要等待分執行緒搜尋結束獲取分執行緒的搜尋結果,再進行綜合,程式碼如下

主執行緒:
        pJunqi->begin_flag = 0;
        pJunqi->bGo = 0;
        pJunqi->bMove = 0;
        iDir = pJunqi->eTurn;
        if( !IsOnlyTwoDir(pJunqi) )
        {
            if( !pJunqi->aInfo[(iDir+1)%4].bDead )
            {
                pMsg->type = SEARCH_RIGHT;//設定訊息型別
                mq_send(pJunqi->search_qid, aBuf, sizeof(SearchMsg), 0);//傳送訊息
                nTread++;//執行緒數加1
            }
            if( !pJunqi->aInfo[(iDir+3)%4].bDead )
            {
                pMsg->type = SEARCH_LEFT;
                mq_send(pJunqi->search_qid, aBuf, sizeof(SearchMsg), 0);
                nTread++;
            }
        }
        while( pJunqi->cntSearch<nTread );//等待所有執行緒都初始化完畢
        pJunqi->begin_flag = 1;//告訴分執行緒主執行緒開始搜尋
        ......
        while(pJunqi->cntSearch);//等所有執行緒搜尋完畢
        //綜合所有搜尋結果,選取最佳著法
        ......

分執行緒:
        pthread_mutex_lock(&pJunqi->search_mutex);
        pJunqiBase->cntSearch++;//增加執行緒計數
        pthread_mutex_unlock(&pJunqi->search_mutex);
        //必須等主執行緒開始搜尋才變更計數,否則會產生死迴圈
        while(!pJunqiBase->begin_flag);
        pJunqiBase->cntSearch--;


2.記憶體池管理

在搜尋過程中需要大量的記憶體分配,主要集中在生產著法InsertMoveList函式和棋盤狀態入棧PushMoveToStack函式,這2個函式在搜尋過程中的呼叫非常頻繁。在malloc的內部實現中是加鎖的,這意味著當一個執行緒在搜尋時,另外一個執行緒需要頻繁的等待,嚴重降低了運算效率。

為了解決這個問題,考慮給每個執行緒物件分配一大塊記憶體池,這塊記憶體池主要在本執行緒內使用,和其他執行緒不共享,那麼也就不用加鎖了。

記憶體池實現的程式碼是從SQLite3的mem5.c檔案中搬過來,採用的buddy演算法,使用前需要先通過memsys5Init()初始化記憶體池,然後用memsys5Malloc()函式代替malloc函式,用memsys5Free函式代替free函式,演算法的具體實現參見我之前先的一篇文章

SQLite3原始碼學習(15) 零-記憶體分配器buddy演算法

3.分類搜尋

目前很難優化使搜尋層數增加,所以採用不同的方式搜尋,最後綜合最佳搜尋結果。當前有以下幾類搜尋,後續會根據除錯再增加。

1.SEARCH_DEFAULT

 預設搜尋四家的行棋,剛好搜4層

2.SEARCH_RIGHT

只搜尋自家和下家的行棋,遇到另外2家跳過,雖然少搜了2家,但是多搜了一個輪次

3.SEARCH_LEFT

只搜尋自家和下家的行棋

4.SEARCH_SINGLE

只搜尋自家的行棋,相當於別人不下,只讓自己連續走幾步什麼著法最好,這種搜尋在僵持階段可以選擇更好的著法

5.SEARCH_PATH

統計每一步行棋後線路上的得分,線路上是否有雷,是否有棋防守,這個沒有做進局面評估裡,因為太耗時間了。

在第一層搜尋時,也就是當前下棋方,搜尋完一步棋時根據設定的搜尋型別pJunqi->eSearchType,記錄下搜尋的分數,並把著法新增到pEngine->ppMoveSort連結串列裡,這裡用的是一個二級指標,因為這個變數要線上程間共享,分配記憶體時也應該用malloc而不是上面的單獨執行緒的記憶體池,程式碼的實現為AddMoveSortList()函式。

這裡要注意的是第一層不能剪枝,因為剪枝後的分數是不準確的,只有搜尋的最佳著法的分數才是準確的,但是為了做比較,現在需要其他著法的準確分數,但是這樣一來搜尋的時間增加了8倍多,後續看看有沒有其他方法優化,現在先這樣

            //0表示不截斷
            if( p->move.result==MOVE && cnt!=1 )
            {
                //這裡考慮的是2打1的情形,下一方是對家行棋時
                if( cnt==2 && pJunqi->aInfo[((pData->iDir-1)&3)].bDead )
                {
                    val = CallAlphaBeta1(pJunqi,depth-1,alpha,beta,pData->iDir,0);
                }
                else
                {
                    val = CallAlphaBeta1(pJunqi,depth-1,alpha,beta,pData->iDir,1);
                }

            }
            else
            {
                //碰撞中有3種情況,不能截斷
                //第一層不截斷,否則無法獲取準確分數
                val = CallAlphaBeta1(pJunqi,depth-1,alpha,beta,pData->iDir,0);
            }

獲取每一種搜尋的分數後,接下來就是選擇最佳著法,現在是將每一種著法的分值相加取最大的,這樣做還是太粗糙了,因為每一階段的搜尋方法的權重是不一樣的,有時某種搜尋尤其是SEARCH_SINGLE型別的搜尋會帶來虛高的分數從而將某一招的分數變得最大,實際上可能由於漏算這一招是不成立,從而無法做出最佳選擇。現在的做法是先對DEFAULT、RIGHT、LEFT三種搜尋的分數相加,根據分數總和排序,如果分數最高的著法有多重選擇,再根據DEFAULT的分數排序,依次類推,直到SEARCH_PATH為止,後續會對最佳著法的選擇做更精細的優化。

void FindBestPathMove(Junqi *pJunqi)
{
    Engine *pEngine = pJunqi->pEngine;
    MoveSort *pHead = *(pEngine->ppMoveSort);
    MoveSort *pNode;
    u8 index;


    if(pHead==NULL)
    {
        return;
    }
    pHead->isHead = 0;
    pHead->pPre->pNext = NULL;//把雙向連結串列變為單向連結串列,在排序時需要

    
    //計算各著法分數總和
    CalSortSumValue(pHead,SEARCH_SUM);
    //對搜尋的結果,根據分數進行排序
    pHead = ResortMoveList(pHead,0);

    //設定排序後的表頭
    *(pEngine->ppMoveSort) = pHead;

    //傳送引擎出招結果
    index = pHead->pHead->index;
    SetBestMove(pJunqi,&pHead->pHead->result[index].move);
}

4.其他細節

這次版本的更新涉及到很多的細節,這裡不詳細說明,只是做一個簡單的記錄。

1.SearchBestMove函式的作用是在上一層搜尋獲得最佳變例後先搜最佳變例,目前來看這段程式碼真的是雞肋,搜尋效能的提升連10%都不到,卻耗費了我大量時間的除錯,而且主要變例的儲存結構和正常搜尋時是不同的,所以在更新著法到排序連結串列中時需要額外處理,極大增加了擴充套件的負擔,如下程式碼,需要增加一個flag進行分別處理

    if( !flag )
    {
        pMove = (MoveList *)pSrc;
    }
    else
    {
        pRslt = (BestMoveList *)pSrc;
    }

2.增加了一個DeepSearch函式,在獲取主要變例後,沿著主要變例進行更深層次的搜尋

3.10步沒吃子後,增加碰撞的分數,主要是為了避免和棋,這裡還有待改進,就是必須局面佔優,這次碰撞不會虧損太低

    if( pJunqi->nNoEat>10  )
    {
        AdjustSortMoveValue(pHead,SEARCH_DEFAULT);
    }

4.一個大子吃了多個子後,敵方的子和這個大子產生碰撞極有可能是被炸,要麼不產生碰撞,這裡需要調整概率,在生成著法時,如果後2排的棋沒動過,那麼可能是雷,考慮動起來的情況也要調整概率

5.自家底排沒動的棋是大子,要把敵方吃下來的著法過濾掉,否則很容易造成底排裝雷騙工兵的棋走動

6.敵方吃掉2個大於37的子時,要額外減去一倍的分數,這是因為有時對方司令吃掉一個大子,而面對另一個大子時,因為那個大子有炸彈保護而不跑,這個目前還沒做

7.大子吃掉棋後,直接面對的棋不炸,那麼認定不是炸彈,這裡還需要改進,比如2個子面對時都沒有炸彈保護,而對面沒有選擇碰撞,這時應認定為小子

8.在CalDangerValue函式中,大於100步在司令未明時不考慮自家防護,這個本來是考慮司令從底下出來,軍旗側的棋都空了,所以死活不打兌司令的情況,因為打兌後的局面評估分數會很低,所以應該在搜尋時固定亮軍旗的標誌位

9.增加了CheckMaxChess函式,主要用來計算令子的分數,及有無令子時炸彈的分數

10.如果只剩2家活著,這時SEARCH_DEFAULT搜尋和SEARCH_LEFT或SEARCH_RIGHT是一樣的,所以增加IsOnlyTwoDir函式判斷是否只剩2家,如果只剩2家則不開啟LEFT和RIGHT搜尋

11.在裝地雷的棋被暗棋撞過後,pDst->pLineup->isNotLand置1,但是前面必須要加一個條件,這個棋不是地雷

12.在LEFT和RIGHT搜尋中過濾掉與另外2家棋的碰撞的著法,在這種搜尋下由於另外2家不行棋,吃掉棋肯定是賺的,但實際情況中很有可能被炸,當然有時吃了後並不會被報復,所以這裡還需要再仔細考慮。

13.考慮到效率問題,棋子的最大型別mx_type在搜尋時並沒有入棧,因為每次搜尋行棋時都會呼叫AdjustMaxType重新更新mx_type,所以關係不大,這裡要注意的是死子的mx_type是由PlayReslut函式設定的所以搜尋時不能更改

5.原始碼

https://github.com/pfysw/JunQi