主题:【原创】一个游戏引发的无血案 -- Highway
【案情背景】
前些日子在河里瞎逛,看到一个面壁朋友问一个小小的问题,就是有没有什么简单的算法来可以解出4张扑克牌用加减乘除拼出24的方案。这其实是一个小孩子们玩的口算游戏,我小时候也常玩儿。于是乎就冒出一个想法,写一个程序让计算机算出所有可能的解决方案来。
有人说了,你是不是吃饱了撑的没事干了,写这破玩艺儿干什么,又不能当钱花?唉,没办法,这可能就是当程序员老落下的职业病吧。据说鞋匠跟人握手的时候会盯着人家的鞋子,刽子手和人家拥抱的时候都情不自禁的打量人家的后脖梗子。程序员做久了,什么事都想写段code来算算,没办法,手就是这么贱!
【一.小试牛刀】
一开始,只想验证一下自己的想法,于是乎就来了个短平快,几下子搞出了一个雏形,踹了两把,Work(一开始有个小bug,被网友们发现了)。哈哈,比较得意。
问题是面壁朋友呢想问我要程序代码,这下子麻烦来了。这个雏形走的是quick-and-dirty路线,程序丑陋的很,见不得人的。并且自己也清楚,这个雏形程序有两处硬伤,一是不灵活,不能玩其他张数的游戏(比如5张牌),另外程序基于string replacement,效率极低。和人玩可能感觉不出来,但是运算量一大,问题就暴露出来了。比如有的朋友说,你能不能把扑克牌所有的组合过一次,看看什么情况下无解。我试着算了一下,大概要7分半钟。是不是太说不过去了,Core 2 Duo的电脑算几张扑克牌居然要一袋烟的功夫,way too slow!!!
【二.鸟枪换炮】
于是乎,开始修正程序的dirty部分,希望能使得程序变得quick。要解决的问题有两个,一个算法要generic,二是效率要高。
一段时间之后,第二版游戏出笼了。这个版本解决了上述的两个问题。玩家可以制定扑克牌张数(以及允许增减运算方法和最后的结果数值),另外String replacement的算法被删除了,改成了native加减乘除。这一下,效率提高近10倍左右。现在把所有组合算一套下来,只需要50秒不到。哇噻!!!。
为了show off一把 ,在做整套扑克牌计算的时候,我使用了多线程,好让计算机的两个“核”都忙起来了,要不买dual core计算机干什么。粗粗一弄,程序提高到了28秒左右, 嗯,爽!
【三.大事不好】
修改后的code第二天带到了公司,准备再把几个细节修改一下就算是大功告成了。很快的问题搞定了。看起来天空晴朗,万里无云。但是没曾想到问题马上就出现了。
新的code单线程运行的时候一切顺利,CPU利用率饱和在50%上面。注意这个小程序是计算密集型的,没有IO或是其它操作。在双CPU(two logic CPU,可以是P4的Hyper-thread,或是dual core, dual socket)的计算机上,CPU的利用率应该是100%左右,也就是全饱和状态。问题就出在了多线程上。一进入多线程状态,CPU利用率不但没有上升,反而下降了,在10-40%之间徘徊。CPU不给出力干活,性能自然就好不了。使用多线程性能下降了近2倍左右。嘿。乖乖!
一看在JDK 5.0平台上不行,我马上切换到了新的JDK 6.0平台上。猛一看,情况喜人,两个CPU都忙活了起来,CPU利用率在95-100%之间徜徉。但问题很快就显示出来了,CPU这么忙活,性能却提高有限,比单个线程只提高了10%左右。呃,这就怪了,CPU利用率从50%上升到了100%,但怎么就出力部出活呢。CPU都忙活什么去了?
我在Pentium 4 Hyper-thread的计算机上,Pentium D(双核)的计算机上做测试,现象惊人的相识。难道是 Windows XP扩展性有问题?不对啊,这也不是头一回在XP机器上使用多线程了。
我将程序上载到了Linux(Red Hat)的服务器上。在两个CPU (两个AMD Opetron 250) 的服务器以及8个CPU的服务器上(8个AMD Opetron 850),情况一样。JVM 5.0在多线程情况下性能大幅下降(程度比windows要好一些),在JVM 6.0平台下,性能只提高10-25%左右。
TNND,邪乎了!
【四.峰回路转】
老革命遇到了新问题,怎么办?
晚上回了家,拎着啤酒瓶子开始默默思考。。。
闲言少叙,经过好一阵子的struggle,问题开始明朗了。白天在单位清理code的时候,为了使double的数字输出美观一些(比如说4/7就会产生一个很长的double),我使用了DecimalFormat类,大概是这样的code
public static final NumberFormat NF = new DecimalFormat("0.00");
问题就出在了这里。这个类不是我想象的stateless的那种utility class,它是一个Stateful class.我这样的语句使得它成了整个程序的瓶颈(static,只有一个instance)。我估计这个程序在某个地方使用了synchronized的程序段,这就造成了多个线程争抢一个lock的局面。
那为什么在JVM 5.0和6.0平台上,CPU利用率以及性能会出现那样的怪现象呢?
我初步推断,问题大概是这样的。
1)JVM 5.0的synchronization机制是传统的“进入或者休息等待” 模式。就是说如果一个线程抢到了lock,它进入critical section,开始执行相应的操作,完成后,释放lock。那么没有抢到lock的线程呢,进入休息等待状态(wait)。其它线程释放了lock以后,它会被notify,重新进入运行状态,再次试着抢lock。在现代计算机里,进入等待状态以及再度激活是一个相当昂贵的操作,会浪费几百个CPU cycle.由于程序中有一个被频繁争抢的lock。这造成了线程大多的时间花费在了“休息—〉激活”的状态切换上,真正执行程序的时间反倒成了少数。于是乎,CPU没有能被很好的利用了起来,这造成了性能的大幅下降。
2)JVM 6.0的synchronization机制有较大改进。其中一个feature叫做spinlock。当一个线程拿不到lock的时候,它不是马上转换到等待状态,而是不离开CPU, “原地打转”。转几圈以后,在回头去抢lock。这就是以前人们所说的Busy waiting。 在以前,Busy Waiting是“坏东西”,是要避免地。但是在当前的软件硬件情况下,先spin一阵子,再试着抢几次lock,不行再转入等待状态效果更好。由于busy waiting的thread不释放CPU,所以CPU利用率居高不下。但是这其中有很多cycle是在“打转儿”,并不出活儿。所以我们就看到了CPU利用率从50%提升到100%,而性能只提升15%-20%左右的现象。原因就在于很多CPU时间是花在了spin上。
找到了问题事情就好办了。我先是写了一个小函数来处理double变成string时的长度问题,替代了那个DecimalFormat类。这样,程序的瓶颈消除了,多线程的威力开始显现。第二天带到公司在各个平台上测试了一圈 ,一切如所想。
(注:数值代表的是完成测试所需要的时间。越小越好。测试是对扑克牌所有组合进行穷尽计算。一次穷尽计算大概1-2秒,我将游戏值从12算到36,算24遍。大概40-50秒。这样计算量购大,时间又不至于太长)
在测试的时候,我突然发现程序里面有好多操作是重复的,完全可以一次性 搞定。另外表达式生成部分算法还有提高的余地。于是乎,对程序再次下手。这一次搞下来,性能又大幅飙升。一套扑克计算下来只需要区区不到俩秒钟,和最初的7分半简直是判若云泥。
到这里,不知不觉地有些飘飘然,对自己的敬仰之情油然而生,牛人就是牛人,没办法!
【五.风云再起】
在单位改好的code被带到了家里,准备在家里的Core 2 Duo计算机上跑几趟。看看在XP,Vista,以及在Windows 2003 Server平台上成绩如何。你猜怎么着,在单位久经考验的程序回了家居然不好使了。注意我带回的是编译好的jar文件,100%原版。在家里,JVM 6.0的环境里,多线程性能有提升,但只 提高了50%左右,这和我期望的80-90%的有很大出入。在JVM 5.0环境里,性能下降的问题又出现了。
是不是我把jar文件搞错了?我马上remote login到公司,把这个jar文件放到公司的计算机上现run一遍,和白天的情形一样,在双核的p4上多线程还是比单线程快90%以上,而在JVM 5.0环境里也不出现性能下降的问题。
难道是遇到了鬼不成?
无奈,我只好用jconsole来跟踪JVM,看看什么地方堵住了。可惜现在的Java监控程序还不能给出我想要的细节,我只能从有限的信息中展开想象。
同样的软件,在公司运行的时候,非常smooth.Total blocked and Total waited number都非常小。这说明线程之间没有相互协调造成的overhead,各个线程几乎是独立运行的。而在家里的计算机里,这两个数字出奇的高,尤其是在JVM 5.0环境下。一样的OS,一样的JVM,一样的程序,怎么会有如此迥异的情形,真是匪夷所思!
我做了N种尝试,最后把新型的for(int[] pokes : Set<int[])的循环改成了老式的(for int i=0; i< array.length; i++),把share的东西全部全部作了独自占有(虽然是只读的数据),这些尝试在JVM 6.0中能看到效果(性能又有一些提高,多线程的scalability也从50%提高到了85%左右)。但问题是在JVM 5.0环境下性能下降的问题依然故我。更奇怪的是同样的code ,同样的JVM 5.0,如果我的操作系统是Windows 2003 Server,下降问题就消失了。但问题是多线程的性能提高却只有36%左右,和理想值想去甚远。
Damn it, I had enough!
到这里,我老人家准备洗手不干了。但就在这时候,事情又有了转机。我clean了一遍code,删除了一些comments,改改format什么的,JVM 5.0好像突然间变好了一样。性能不再下降了,在XP和Vista平台上,也居然能有50%提高。不过一Reboot machine, 问题有出现了。TLLD,这不是逗我玩吗?我老人家这下是彻底晕菜了。。。
【六.草草结案】
自认为在多线程问题上有几年功力,但这次这个小程序搞得我有些灰头土脸。当然我有不当的地方,但是Java本身也确实还不够完美。有很多问题我认为是它具体实现上的问题。不过呢,JDK 6.0要好很多,不仅所有的测试要比5.0快一些,而且似乎更可靠一些,更“makes sense”。所以呢,建议大家尽早升级到6.0上来。
Linux平台上的Java扩展性能相当稳定,不过不知道这是Linux的功劳,而是AMD Opetron结构优势的体现。我没有在相同的AMD平台上测试Windows的表现,所以不好下结论。
Core 2 Duo确实优秀。2.13GHz的E6400几乎比所有的处理器都快,所以大家如果购买计算机,我认为这是首选。
另外就游戏本身而言,我认为4张扑克牌算24是个比较折中的方案。4张牌,加减乘除口算可以基本解决。不过呢,算24无解的情况比较多,如果改为2那么无解情况就会极大地减少。可能那样玩起来不够劲,2看起来太简单了一些。5张牌口算可能还能勉强应付,6张牌我想一般人就handle不了了。没个计算器什么的估计是不好解决。不信,你用4,5,6,7,8,9给我算个5188(吾要发发)出来。计算机都得算个好几秒钟呢。如果是玩8张牌的游戏,计算机都够呛,没个几个钟头是搞不定的。所以说别小看这些排列组合问题。搞基因蛋白质的,动辄要上万台计算机上千个小时的计算,问题和这个异曲同工。据说这个领域已经变成了计算最密集的行业,需求不可思议的巨大。
不早了,水就先灌到这里吧!
花!为小事入手,踏实研究。
遗憾!铁老大不让连续送花...
太专业了,太敬业了!!!
Only if every software can be re-coded and re-tested like you did!!!
Otherwise we'll all be like ants on a hotpot...
一定要花了。highway老大能不能介绍点java6的features,我看得都流口水了。
Java有2年没碰了,都快忘了,轧老大什么时候科普一下JVM 6?
看到结尾处,算8张牌,忽然想起以前学过的人工智能的课,感觉又多了一个实例。
因为MATLAB是解释性语言,第一次跑的时候,速度比较慢,需要解释编译,接下去跑的时候,可以利用前面的已经缓存了的东西,速度就快了。
不过,你那个看起来这种可能性太小。
俺的考试压力现在依然还有,所以时不时地问上几个弱智问题,望highway兄和众河友多多担待。
我的游戏算法大概是这样的:
1) 任意给四个1-13之间的数字
2) 求出所有的排列来。
3) 把加减乘除运算符加进去,列出所有可能的计算表达式(优先级也要考虑)
4) 对每个表达式进行求解,如果是24,那么这个表达式就是一个答案。
对于四张牌,全循环一次40秒左右
俺一直没好意思问,怎样才算一次全循环?就是说把所有的可能性都找一遍吗?俺的C程序也能给出答案(嘿嘿,有些输出只有俺能看懂)但给出答案怎么会比40秒要快很多?!
highway兄和众河友们不妨解决一下我前段碰到的这样一个问题:
#include <iostream>
#include <ctime>
using namespace std;
int main()
{
clock_t start,end;
start=clock();
cout<<start<<'\n';
for (int i=0;i<10000;i++)
{ for (int i=0;i<10000;i++); }
end=clock();
cout<<end<<'\n';
cout<<"ticks= "<<(end-start)<<'\n';
cout<<CLOCKS_PER_SEC<<'\n';
}
这2个for空循环在cygwin编译器上运行,41秒!而在Microsoft VC++ 2005 上,即使再添几个0,
for (int i=0;i<10000000;i++)
{ for (int i=0;i<10000000;i++); }
也是0秒(release)! 怎么回事?
24点的计算里如果只考虑四则计算,其中任何一个计算都是A(+-*/)B=C
所以对于四个数比如1,2,3,4
先在四个数里任意选择两个数,在这两个数里做加减乘除得到一个新数,把这个新数和剩下的两个数和在一起,这就只有三个数了,在这三个数里在选两个做加减乘除就可再减少一个数,如此类推,到最后剩下两个数在做加减乘除看是不是等于24.
所以总共的可能组合有:(4个里选两个)*(4种运算)*(3个里选两个)*4*(2个里选两个)*4=1128种,这样就是586都可以很快出来结果
注意,这个组合不是一个传统意义上的52张牌取4张的概率问题。因为在这个游戏中,牌的花色不重要。也就是说3个A搭配一个黑桃2和搭配一个红桃2是一样的。到底4张牌能给出多少种不同的组合还有点小trick在里面,不是想象的那么简单。
你的那个问题可能是这样的。那段code有没有对程序最后结果没有影响,编译器可能给你自动删除了。以前在微软的Visual J++我就见过这样的东西。所以你在循环体里面可以做一些简单计算,把结果最后cout一下,这样编译器就不会给你删除了。
不知道这个“弱智”的解答对你是不是有所帮助?
对于加法和乘法,N张牌取两张是一个组合问题,因为两个数字顺序无关。但对已减法和除法,则是一个排列问题,因为谁减谁,谁除谁顺序很重要。
我有空的话在琢磨一下你的这个建议。