この記事では、以前の記事シリーズで実装された C/Python コード統計ツール (CPLineCounter) の C 拡張インターフェイスを介した最適化のコア アルゴリズムを書き直し、インターネット上の一般的な統計ツールと比較します。実際の測定により、CPLineCounter は統計精度とパフォーマンスの点で他の同様の統計ツールよりも優れていることがわかります。数千万行のコードを例としてパフォーマンスを評価すると、CPLineCounter を Cpython および Pypy 環境で実行すると、外部統計ツール cloc1.64 よりそれぞれ 14.5 倍と 29 倍、1.8 倍と 3.6 倍高速になります。国内のSourceCounter3.4よりも高速です。
テスト環境を実行する
この記事は Windows システム プラットフォームに基づいており、関連するコード例を実行してテストします。プラットフォーム情報は次のとおりです:
異なるバージョンの Python では構文の違いがあるため、記事内の一部のコード例は、下位バージョンの Python 環境で実行するためにわずかに変更する必要があることに注意してください。
1. コードの実装と最適化
断片化を避けるために、このセクションでは完全な実装コードを示します。このセクションの一部の変数または関数の定義は、これまでの記事シリーズの実装とは若干異なることに注意してください。スクリーニングに注意してください。
1.1 コードの実装
まず、統計結果を保存する 2 つのリストを定義します。
このうち、rawCountInfo には、ファイル行数、コード行数、コメント行数、空白行数の合計とファイル数が格納されます。 detailCountInfo には、1 つのファイルの行数情報とファイル名、およびすべてのファイルの行数の合計などの詳細な統計情報が格納されます。
具体的な実装コードを以下に示します。コードの大きなセクションを貼り付けることを避けるために、関数を簡単に説明します。
CalcLinesCh() 関数と CalcLinesPy() 関数は、それぞれ C 構文と Python 構文に基づいてファイル行属性を決定し、それぞれコード、コメント、または空白行ごとにカウントします。
実行速度を向上させるために、著者は CalcLinesCh() 関数と CalcLinesPy() 関数を C 言語で書き直し、ダイナミック リンク ライブラリにコンパイルしました。これら 2 つの関数の C 言語バージョンの実装と使用の詳細については、セクション 1.2 を参照してください。 LoadCExtLib() 関数と CalcLines() 関数は、ダイナミック リンク ライブラリをロードし、対応する C バージョンの統計関数を実行するように設計されています。ロードが失敗した場合は、より遅い Python バージョンの統計関数が実行されます。
上記のコードは CPython 環境で実行され、C ダイナミック ライブラリは Python2.5 以降のバージョンの組み込み ctypes モジュールを通じてロードおよび実行されます。このモジュールは、Python の外部関数ライブラリとして、C 言語と互換性のあるデータ型を提供し、DLL または共有ライブラリ内の関数を呼び出すことができます。したがって、ctypes は、純粋な Python コードで外部動的ライブラリをラップするためによく使用されます。
コードが Pypy 環境で実行される場合は、cffi インターフェイスを使用して C プログラムを呼び出す必要があります。
cffi の使用法は ctypes に似ていますが、C ファイルを直接ロードして内部の関数を呼び出すことができます (解釈中に自動的にコンパイルされます)。統一のため、ここでも動的ライブラリをロードする方法が使用されています。
「%d ファイルが処理されました...」という進行状況プロンプトに注意してください。コマンド ラインを介して出力がファイルにリダイレクトされるかどうかを知ることは不可能であるため (sys.stdout は変更されず、sys.argv には ">out" が含まれていません)、進行状況プロンプトによって出力ファイルに改行が書き込まれます。コード ファイルの数が N であると仮定すると、出力ファイルには N 行の進行状況情報が含まれます。現時点では、リダイレクトがデフォルトで標準出力にのみ影響する機能を使用して、標準エラーからコンソールに進行状況情報を出力することしかできません。同時に -o オプションを追加して標準出力とファイル書き込みを明示的に区別し、ユーザーのリダイレクトを減らします。リスク。
さらに、CalcLines() 関数を呼び出すとき、strip() メソッドを使用してファイル行の先頭と末尾の空白文字が削除されます。したがって、CalcLinesCh()およびCalcLinesPy()における行終端判定分岐は必要ありません。
ReportCounterInfo() は統計レポートを出力します。なお、詳細レポートの出力前に、指定した振り分けルールに従って出力内容が振り分けられます。さらに、空白行の用語が EmptyLines から BlankLines に変更されました。前者は、その行に行末文字以外の文字が含まれていないことを意味し、後者は、その行に空白文字 (スペース、タブ、行末文字など) のみが含まれていることを意味します。
複数のディレクトリやファイルの同時カウントをサポートするには、ParseTargetList() を使用してディレクトリとファイルの混合リストを解析し、その要素をディレクトリ リストとファイル リストにそれぞれ保存します。
リーリー
-c キャッシュ オプションは、出力照合順序を変更する場合に最も役立ちます。このオプションをサポートするには、Json モジュールを使用して統計レポートを永続化します:
注意,json持久化会涉及字符编码问题。例如,当源文件名包含gbk编码的中文字符时,文件名写入detailCountInfo前应通过unicode(os.path.basename(filePath), 'gbk')转换为Unicode,否则dump时会报错。幸好,只有测试用的源码文件才可能包含中文字符。因此,通常不用考虑编码问题。
此时,可调用以上函数统计代码并输出报告:
def main(): global gIsStdout, rawCountInfo, detailCountInfo (keep, detail, basename, sort, out, cache, target) = ParseCmdArgs() stream = sys.stdout if not out else open(out, 'w') SetSortArg(sort); LoadCExtLib() cacheUsed = shouldUseCache(keep, detail, basename, cache, target) if cacheUsed: try: (rawCountInfo, detailCountInfo) = CounterLoad() except (EOFError, ValueError), e: #不太可能出现 print >>sys.stderr, 'Unexpected Cache Corruption(%s), Try Counting Directly.'%e LineCounter(keep, not detail, basename, target) else: LineCounter(keep, not detail, basename, target) ReportCounterInfo(not detail, stream) CounterDump((keep, detail, basename, target)) CounterDump((rawCountInfo, detailCountInfo))
为测量行数统计工具的运行效率,还可添加如下计时代码:
if __name__ == '__main__': from time import clock startTime = clock() main() endTime = clock() print >>sys.stderr, 'Time Elasped: %.2f sec.' %(endTime-startTime)
为避免cProfile开销,此处使用time.clock()测量耗时。
1.2 代码优化
CalcLinesCh()和CalcLinesPy()除len()函数外并未使用其他Python库函数,因此很容易改写为C实现。其C语言版本实现最初如下:
#include <stdio.h> #include <string.h> #define TRUE 1 #define FALSE 0 unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) { unsigned int lineType = 0; unsigned int lineLen = strlen(line); if(!lineLen) return lineType; char *expandLine = calloc(lineLen + 1/*\n*/, 1); if(NULL == expandLine) return lineType; memmove(expandLine, line, lineLen); expandLine[lineLen] = '\n'; //添加一个字符防止iChar+1时越界 unsigned int iChar = 0; unsigned char isLineComment = FALSE; while(iChar < lineLen) { if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') { //空白字符 iChar += 1; continue; } else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '/') { //行注释 isLineComment = TRUE; lineType |= 2; iChar += 1; //跳过'/' } else if(expandLine[iChar] == '/' && expandLine[iChar+1] == '*') { //块注释开始符 isBlockComment[0] = TRUE; lineType |= 2; iChar += 1; } else if(expandLine[iChar] == '*' && expandLine[iChar+1] == '/') { //块注释结束符 isBlockComment[0] = FALSE; lineType |= 2; iChar += 1; } else { if(isLineComment || isBlockComment[0]) lineType |= 2; else lineType |= 1; } iChar += 1; } free(expandLine); return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释 } unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) { //isBlockComment[single quotes, double quotes] unsigned int lineType = 0; unsigned int lineLen = strlen(line); if(!lineLen) return lineType; char *expandLine = calloc(lineLen + 2/*\n\n*/, 1); if(NULL == expandLine) return lineType; memmove(expandLine, line, lineLen); //添加两个字符防止iChar+2时越界 expandLine[lineLen] = '\n'; expandLine[lineLen+1] = '\n'; unsigned int iChar = 0; unsigned char isLineComment = FALSE; while(iChar < lineLen) { if(expandLine[iChar] == ' ' || expandLine[iChar] == '\t') { //空白字符 iChar += 1; continue; } else if(expandLine[iChar] == '#') { //行注释 isLineComment = TRUE; lineType |= 2; } else if(expandLine[iChar] == '\'' && expandLine[iChar+1] == '\'' && expandLine[iChar+2] == '\'') { //单引号块注释 if(isBlockComment[0] || isBlockComment[1]) isBlockComment[0] = FALSE; else isBlockComment[0] = TRUE; lineType |= 2; iChar += 2; } else if(expandLine[iChar] == '"' && expandLine[iChar+1] == '"' && expandLine[iChar+2] == '"') { //双引号块注释 if(isBlockComment[0] || isBlockComment[1]) isBlockComment[1] = FALSE; else isBlockComment[1] = TRUE; lineType |= 2; iChar += 2; } else { if(isLineComment || isBlockComment[0] || isBlockComment[1]) lineType |= 2; else lineType |= 1; } iChar += 1; } free(expandLine); return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释 }
这种实现最接近原来的Python版本,但还能进一步优化,如下:
#define TRUE 1 #define FALSE 0 unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) { unsigned int lineType = 0; unsigned int iChar = 0; unsigned char isLineComment = FALSE; while(line[iChar] != '\0') { if(line[iChar] == ' ' || line[iChar] == '\t') { //空白字符 iChar += 1; continue; } else if(line[iChar] == '/' && line[iChar+1] == '/') { //行注释 isLineComment = TRUE; lineType |= 2; iChar += 1; //跳过'/' } else if(line[iChar] == '/' && line[iChar+1] == '*') { //块注释开始符 isBlockComment[0] = TRUE; lineType |= 2; iChar += 1; } else if(line[iChar] == '*' && line[iChar+1] == '/') { //块注释结束符 isBlockComment[0] = FALSE; lineType |= 2; iChar += 1; } else { if(isLineComment || isBlockComment[0]) lineType |= 2; else lineType |= 1; } iChar += 1; } return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释 } unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) { //isBlockComment[single quotes, double quotes] unsigned int lineType = 0; unsigned int iChar = 0; unsigned char isLineComment = FALSE; while(line[iChar] != '\0') { if(line[iChar] == ' ' || line[iChar] == '\t') { //空白字符 iChar += 1; continue; } else if(line[iChar] == '#') { //行注释 isLineComment = TRUE; lineType |= 2; } else if(line[iChar] == '\'' && line[iChar+1] == '\'' && line[iChar+2] == '\'') { //单引号块注释 if(isBlockComment[0] || isBlockComment[1]) isBlockComment[0] = FALSE; else isBlockComment[0] = TRUE; lineType |= 2; iChar += 2; } else if(line[iChar] == '"' && line[iChar+1] == '"' && line[iChar+2] == '"') { //双引号块注释 if(isBlockComment[0] || isBlockComment[1]) isBlockComment[1] = FALSE; else isBlockComment[1] = TRUE; lineType |= 2; iChar += 2; } else { if(isLineComment || isBlockComment[0] || isBlockComment[1]) lineType |= 2; else lineType |= 1; } iChar += 1; } return lineType; //Bitmap:0空行,1代码,2注释,3代码和注释 }
优化后的版本利用&&运算符短路特性,因此不必考虑越界问题,从而避免动态内存的分配和释放。
作者的Windows系统最初未安装Microsoft VC++工具,因此使用已安装的MinGW开发环境编译dll文件。将上述C代码保存为CalcLines.c,编译命令如下:
gcc -shared -o CalcLines.dll CalcLines.c
注意,MinGW中编译dll和编译so的命令相同。-shared选项指明创建共享库,在Windows中为dll文件,在Unix系统中为so文件。
其间,作者还尝试其他C扩展工具,如PyInline。在http://pyinline.sourceforge.net/下载压缩包,解压后拷贝目录PyInline-0.03至Lib\site-packages下。在命令提示符窗口中进入该目录,执行python setup.py install安装PyInline
执行示例时提示BuildError: error: Unable to find vcvarsall.bat。查阅网络资料,作者下载Microsoft Visual C++ Compiler for Python 2.7并安装。然而,实践后发现PyInline非常难用,于是作罢。
由于对MinGW编译效果存疑,作者最终决定安装VS2008 Express Edition。之所以选择2008版本,是考虑到CPython2.7的Windows版本基于VS2008的运行时(runtime)库。安装后,在C:\Program Files\Microsoft Visual Studio 9.0\VC\bin目录可找到cl.exe(编译器)和link.exe(链接器)。按照网络教程设置环境变量后,即可在Visual Studio 2008 Command Prompt命令提示符中编译和链接程序。输入cl /help或cl -help可查看编译器选项说明。
将CalcLines.c编译为动态链接库前,还需要对函数头添加_declspec(dllexport),以指明这是从dll导出的函数:
_declspec(dllexport) unsigned int CalcLinesCh(char *line, unsigned char isBlockComment[2]) {...
_declspec(dllexport) unsigned int CalcLinesPy(char *line, unsigned char isBlockComment[2]) {...
否则Python程序加载动态库后,会提示找不到相应的C函数。
添加函数导出标记后,执行如下命令编译源代码:
cl /Ox /Ot /Wall /LD /FeCalcLines.dll CalcLines.c
其中,/Ox选项表示使用最大优化,/Ot选项表示代码速度优先。/LD表示创建动态链接库,/Fe指明动态库名称。
动态库文件可用UPX压缩。由MinGW编译的dll文件,UPX压缩前后分别为13KB和11KB;而VS2008编译过的dll文件,UPX压缩前后分别为41KB和20KB。经测两者速度相当。考虑到动态库体积,后文仅使用MinGW编译的dll文件。
使用C扩展的动态链接库,代码统计工具在CPython2.7环境下可获得极大的速度提升。相对而言,Pypy因为本身加速效果显著,动态库的性能提升反而不太明显。此外,当待统计文件数目较少时,也可不使用dll文件(此时将启用Python版本的算法);当文件数目较多时,dll文件会显著提高统计速度。详细的评测数据参见第二节。
作者使用的Pypy版本为5.1,可从官网下载Win32安装包。该安装包默认包含cffi1.6,后者的使用可参考《Python学习入门手册以及CFFI》或CFFI官方文档。安装Pypy5.1后,在命令提示符窗口输入pypy可查看pypy和cffi版本信息:
E:\PyTest>pypy Python 2.7.10 (b0a649e90b66, Apr 28 2016, 13:11:00) [PyPy 5.1.1 with MSC v.1500 32 bit] on win32 Type "help", "copyright", "credits" or "license" for more information. >>>> import cffi >>>> cffi.__version__ '1.6.0'
若要CPLineCounter在未安装Python环境的主机上运行,应先将CPython版本的代码转换为exe并压缩后,连同压缩后的dll文件一并发布。使用者可将其放入同一个目录,再将该目录加入PATH环境变量,即可在Windows命令提示符窗口中运行CPLineCounter。例如:
D:\pytest>CPLineCounter -d lctest -s code FileLines CodeLines CommentLines BlankLines CommentPercent FileName 6 3 4 0 0.57 D:\pytest\lctest\hard.c 27 7 15 5 0.68 D:\pytest\lctest\file27_code7_cmmt15_blank5.py 33 19 15 4 0.44 D:\pytest\lctest\line.c 44 34 3 7 0.08 D:\pytest\lctest\test.c 44 34 3 7 0.08 D:\pytest\lctest\subdir\test.c 243 162 26 60 0.14 D:\pytest\lctest\subdir\CLineCounter.py ------------------------------------------------------------------------------------------ 397 259 66 83 0.20 <Total:6 Code Files> Time Elasped: 0.04 sec.
二. 精度与性能评测
为检验CPLineCounter统计精度和性能,作者从网上下载几款常见的行数统计工具,即cloc1.64(10.9MB)、linecount3.7(451KB)、SourceCounter3.4(8.34MB)和SourceCount_1.0(644KB)。
首先测试统计精度。以line.c为目标代码,上述工具的统计输出如下表所示("-"表示该工具未直接提供该统计项):
经
人工检验,CPLineCounter的统计结果准确无误。linecount和SourceCounter统计也较为可靠。
然后,统计82个源代码文件,上述工具的统计输出如下表所示:
通常,文件总行数和空行数统计规则简单,不易出错。因此,选取这两项统计重合度最高的工具作为基准,即CPLineCounter和linecount。同时,对于代码行数和注释行数,CPLineCounter和SourceCounter的统计结果重合。根据统计重合度,有理由认为CPLineCounter的统计精度最高。
最后,测试统计性能。在作者的Windows XP主机(Pentium G630 2.7GHz主频2GB内存)上,统计5857个C源代码文件,总行数接近千万级。上述工具的性能表现如下表所示。表中仅显示总计项,实际上仍统计单个文件的行数信息。注意,测试时linecount要勾选"目录统计时包含同名文件",cloc要添加--skip-uniqueness和--by-file选项。
其中,CPLineCounter的性能因运行场景而异,统计耗时少则29秒,多则281秒。。需要注意的是,cloc仅统计出5733个文件。
以条形图展示上述工具的统计性能,如下所示:
图中"Opt-c"表示CPLineCounter以-c选项运行,"CPython2.7+ctypes(O)"表示以CPython2.7环境运行附带旧DLL库的CPLineCounter,"Pypy5.1+cffi1.6(N)"表示以Pypy5.1环境运行附带新DLL库的CPLineCounter,以此类推。
由于CPLineCounter并非纯粹的CPU密集型程序,因此DLL库算法本身的优化并未带来性能的显著提升(对比旧DLL库和新DLL库)。对比之下,Pypy内置JIT(即时编译)解释器,可从整体上极大地���升Python脚本的运行速度,加速效果甚至可与C匹敌。此外,性能测试数据会受到目标代码、CPU架构、预热、缓存、后台程序等多方面因素影响,因此不同工具或组合的性能表现可能与作者给出的数据略有出入。
综合而言,CPLineCounter统计速度最快且结果可靠,软件体积也小(exe1.3MB,dll11KB)。SourceCounter统计结果比较可靠,速度较快,且内置项目管理信息。cloc文件数目统计误差大,linecount代码行统计误差大,两者速度较慢。但cloc可配置项丰富,并且可自行编译以压缩体积。SourceCount统计速度最慢,结果也不太可靠。
了解Python并行计算的读者也可修改CPLineCounter源码实现,加入多进程处理,压满多核处理器;还可尝试多线程,以改善IO性能。以下截取CountFileLines()函数的部分line_profiler结果:
E:\PyTest>kernprof -l -v CPLineCounter.py source -d > out.txt 140872 93736 32106 16938 0.26 <Total:82 Code Files> Wrote profile results to CPLineCounter.py.lprof Timer unit: 2.79365e-07 s Total time: 5.81981 s File: CPLineCounter.py Function: CountFileLines at line 143 Line # Hits Time Per Hit % Time Line Contents ============================================================== 143 @profile 144 def CountFileLines(filePath, isRawReport=True, isShortName=False): ... ... ... ... ... ... ... ... 162 82 7083200 86380.5 34.0 with open(filePath, 'r') as file: 163 140954 1851877 13.1 8.9 for line in file: 164 140872 6437774 45.7 30.9 lineType = CalcLines(fileType, line.strip(), isBlockComment) 165 140872 1761864 12.5 8.5 lineCountInfo[0] += 1 166 140872 1662583 11.8 8.0 if lineType == 0: lineCountInfo[3] += 1 167 123934 1499176 12.1 7.2 elif lineType == 1: lineCountInfo[1] += 1 168 32106 406931 12.7 2.0 elif lineType == 2: lineCountInfo[2] += 1 169 1908 27634 14.5 0.1 elif lineType == 3: lineCountInfo[1] += 1; lineCountInfo[2] += 1 ... ... ... ... ... ... ... ...
line_profiler可用pip install line_profiler安装。在待评估函数前添加装饰器@profile后,运行kernprof命令,将给出被装饰函数中每行代码所耗费的时间。-l选项指明逐行分析,-v选项则指明执行后屏显计时信息。Hits(执行次数)或Time(执行时间)值较大的代码行具有较大的优化空间。
由line_profiler结果可见,该函数偏向CPU密集型(75~80行占用该函数56.7%的耗时)。然而考虑到目录遍历等操作,很可能整体程序为IO密集型。因此,选用多进程还是多线程加速还需要测试验证。最简单地,可将73~80行(即读文件和统计行数)均改为C实现。其他部分要么为IO密集型要么使用Python库,用C语言改写事倍功半。
最後に、コードの行数のみをカウントする場合は、Linux または Mac システムで次のシェル コマンドを使用できます:
find ./codeDir -name "*.c" -or -name "*.h" #空白行を除く総行数
find ./codeDir -name "*.c" -or -name "*.h" #各ファイルの行数と合計
以上がこの記事の全内容です。皆様の学習に役立つことを願っています。また、皆様も Script House をサポートしていただければ幸いです。