四国军棋引擎开发(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

猜你喜欢

转载自blog.csdn.net/pfysw/article/details/85523620