小消息,大计算及MapReduce索引实践
1. 并行计算基础与pmap拓展
在并行计算领域,
pmap
函数有着重要的地位。它不仅可以将计算任务分配到多核 CPU 的各个进程中,还具备将计算任务分布到分布式网络的不同节点上的潜力。虽然这里不详细介绍如何实现分布式的
pmap
,但我们要知道,利用基本的
spawn
、
send
和
receive
原语,能够轻松构建出大量的抽象概念。这些原语可以帮助我们创建自己的并行控制抽象,从而提升程序的并发性能。而提升并发性能的关键在于避免副作用,这一点必须牢记。
2. 小消息与大计算实验
为了更直观地了解并行计算的性能,我们进行了两个实验。分别将两个函数映射到包含 100 个元素的列表上,并比较顺序映射和并行映射所花费的时间。
2.1 实验问题集
-
问题一
:
-
定义列表
L = [L1, L2, ..., L100],其中每个Li是包含 1000 个随机整数的列表。 -
执行
map(fun lists:sort/1, L)操作。
-
定义列表
-
问题二
:
-
定义列表
L = [27, 27, ..., 27],列表长度为 100。 -
执行
map(fun ptests:fib/1, L)操作,其中fib是斐波那契函数。
-
定义列表
2.2 实验分析
在第一个计算(排序)中使用
pmap
时,不同进程之间的消息传递需要传输相对大量的数据(每个列表包含 1000 个随机数),不过排序过程本身相对较快。而第二个计算只需要向每个进程发送一个小请求(计算
fib(27)
),但递归计算
fib(27)
的工作量相对较大。由于计算
fib(27)
时进程间的数据复制较少且计算量较大,我们预计第二个问题在多核 CPU 上的性能会优于第一个问题。
3. 运行 SMP Erlang
SMP(对称多处理)Erlang 可以在多种不同的架构和操作系统上运行,如支持 Intel 双核和四核处理器、Sun 和 Cavium 处理器等。自 R11B - 0 版本起,在已知支持 SMP Erlang 的所有平台上,SMP 虚拟机默认构建。若要在其他平台上强制构建 SMP Erlang,需在
configure
命令中添加
--enable - smp - support
标志。
SMP Erlang 有两个命令行标志用于控制其在多核 CPU 上的运行方式:
-
-smp
:启动 SMP Erlang。
-
+S N
:使用
N
个调度器运行 Erlang。每个 Erlang 调度器都是一个完整的虚拟机,并且了解其他所有虚拟机的情况。若省略该参数,默认使用 SMP 机器中的逻辑处理器数量。
我们可能需要调整调度器数量的原因如下:
- 进行性能测量时,通过改变调度器数量来观察不同 CPU 数量对运行效果的影响。
- 在单核 CPU 上,通过改变
N
来模拟多核 CPU 的运行情况。
- 有时使用比物理 CPU 数量更多的调度器可以提高吞吐量,使系统表现更优,但这些效果尚未完全明确,仍在积极研究中。
3.1 测试脚本
为了执行上述实验,我们需要一个测试脚本
runtests
:
#!/bin/sh
echo "" >results
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
do
echo $i
erl -boot start_clean -noshell -smp +S $i \
-s ptests tests $i >> results
done
该脚本会启动 Erlang,使用 1 到 32 个不同的调度器运行计时测试,并将所有计时结果收集到一个名为
results
的文件中。
3.2 测试程序
测试程序
ptests.erl
代码如下:
-module(ptests).
-export([tests/1, fib/1]).
-import(lists, [map/2]).
-import(lib_misc, [pmap/2]).
tests([N]) ->
Nsched = list_to_integer(atom_to_list(N)),
run_tests(1, Nsched).
run_tests(N, Nsched) ->
case test(N) of
stop ->
init:stop();
Val ->
io:format("~p.~n",[{Nsched, Val}]),
run_tests(N+1, Nsched)
end.
test(1) ->
%% Make 100 lists
%% Each list contains 1000 random integers
seed(),
S = lists:seq(1,100),
L = map(fun(_) -> mkList(1000) end, S),
{Time1, S1} = timer:tc(lists,
map,
[fun lists:sort/1, L]),
{Time2, S2} = timer:tc(lib_misc, pmap, [fun lists:sort/1, L]),
{sort, Time1, Time2, equal(S1, S2)};
test(2) ->
%% L = [27,27,27,..] 100 times
L = lists:duplicate(100, 27),
{Time1, S1} = timer:tc(lists,
map,
[fun ptests:fib/1, L]),
{Time2, S2} = timer:tc(lib_misc, pmap, [fun ptests:fib/1, L]),
{fib, Time1, Time2, equal(S1, S2)};
test(3) ->
stop.
%% Equal is used to test that map and pmap compute the same thing
equal(S,S) -> true;
equal(S1,S2) -> {differ, S1, S2}.
%% recursive (inefficent) fibonacci
fib(0) -> 1;
fib(1) -> 1;
fib(N) -> fib(N-1) + fib(N-2).
%% Reset the random number generator. This is so we
%% get the same sequence of random numbers each time we run
%% the program
seed() -> random:seed(44,55,66).
%% Make a list of K random numbers
%% Each random number in the range 1..1000000
mkList(K) -> mkList(K, []).
mkList(0, L) -> L;
mkList(N, L) -> mkList(N-1, [random:uniform(1000000)|L]).
通过这个测试程序,我们可以运行顺序映射和并行映射的两种测试用例。从实验结果的图表(图 20.1)中可以看出,CPU 密集型且消息传递较少的计算具有线性加速效果,而消息传递较多的轻量级计算的扩展性则相对较差。需要注意的是,SMP Erlang 每天都在不断发展变化,当前的实验结果可能在未来并不适用。不过,我们对实验结果还是感到非常鼓舞,例如爱立信正在开发的商业产品在双核处理器上的运行速度几乎提高了一倍。
4. MapReduce 与磁盘索引
4.1 MapReduce 原理
MapReduce 是一种并行高阶函数,由 Google 的 Jeffrey Dean 和 Sanjay Ghemawat 提出,据说在 Google 集群中每天都在使用。其基本思想是有多个映射进程生成
{Key, Value}
对的流,然后将这些对发送给一个归约进程,归约进程会合并具有相同键的对。
MapReduce 的定义如下:
@spec mapreduce(F1, F2, Acc0, L) -> Acc
F1 = fun(Pid, X) -> void,
F2 = fun(Key, [Value], Acc0) -> Acc
L = [X]
Acc = X = term()
-
F1(Pid, X)是映射函数,其任务是向进程Pid发送{Key, Value}消息流,然后终止。对于列表L中的每个X值,MapReduce 会生成一个新的进程。 -
F2(Key, [Value], Acc0) -> Acc是归约函数。当所有映射进程终止后,归约进程会合并特定键的所有值,然后对收集到的每个{Key, [Value]}对调用F2函数。Acc是一个累加器,初始值为Acc0,F2函数返回一个新的累加器。
MapReduce 在
phofs
模块中定义:
-module(phofs).
-export([mapreduce/4]).
-import(lists, [foreach/2]).
%% F1(Pid, X) -> sends {Key,Val} messages to Pid
%% F2(Key, [Val], AccIn) -> AccOut
mapreduce(F1, F2, Acc0, L) ->
S = self(),
Pid = spawn(fun() -> reduce(S, F1, F2, Acc0, L) end),
receive
{Pid, Result} ->
Result
end.
reduce(Parent, F1, F2, Acc0, L) ->
process_flag(trap_exit, true),
ReducePid = self(),
%% Create the Map processes
%% One for each element X in L
foreach(fun(X) ->
spawn_link(fun() -> do_job(ReducePid, F1, X) end)
end, L),
N = length(L),
%% make a dictionary to store the Keys
Dict0 = dict:new(),
%% Wait for N Map processes to terminate
Dict1 = collect_replies(N, Dict0),
Acc = dict:fold(F2, Acc0, Dict1),
Parent ! {self(), Acc}.
%% collect_replies(N, Dict)
%% collect and merge {Key, Value} messages from N processes.
%% When N processes have terminated return a dictionary
%% of {Key, [Value]} pairs
collect_replies(0, Dict) ->
Dict;
collect_replies(N, Dict) ->
receive
{Key, Val} ->
case dict:is_key(Key, Dict) of
true ->
Dict1 = dict:append(Key, Val, Dict),
collect_replies(N, Dict1);
false ->
Dict1 = dict:store(Key,[Val], Dict),
collect_replies(N, Dict1)
end;
{'EXIT', _, Why} ->
collect_replies(N-1, Dict)
end.
%% Call F(Pid, X)
%% F must send {Key, Value} messsages to Pid
%% and then terminate
do_job(ReducePid, F, X) ->
F(ReducePid, X).
4.2 MapReduce 测试
为了更清楚地了解 MapReduce 的工作原理,我们编写了一个小程序来统计代码目录中所有单词的出现频率:
-module(test_mapreduce).
-compile(export_all).
-import(lists, [reverse/1, sort/1]).
test() ->
wc_dir(".").
wc_dir(Dir) ->
F1 = fun generate_words/2,
F2 = fun count_words/3,
Files = lib_find:files(Dir, "*.erl", false),
L1 = phofs:mapreduce(F1, F2, [], Files),
reverse(sort(L1)).
generate_words(Pid, File) ->
F = fun(Word) -> Pid ! {Word, 1} end,
lib_misc:foreachWordInFile(File, F).
count_words(Key, Vals, A) ->
[{length(Vals), Key}|A].
运行这个程序,例如
test_mapreduce:test()
,可以得到每个单词的出现频率统计结果。在代码目录中有 102 个 Erlang 模块时,MapReduce 会创建 102 个并行进程,每个进程向归约进程发送
{Key, Value}
对的流。如果磁盘性能能够跟上,这个程序在 100 核处理器上应该能很好地运行。
5. 全文索引
5.1 倒排索引概念
在构建索引时,我们需要找出文件中的所有单词,这将用于 MapReduce 操作的“映射”阶段。我们使用倒排索引来实现全文索引,下面通过一个简单的例子来说明倒排索引的概念。
假设有一个文件系统,包含三个文件,文件内容如下:
| 文件名 | 内容 |
| ---- | ---- |
| /home/dogs | rover jack buster winston |
| /home/animals/cats | zorro daisy jaguar |
| /home/cars | rover jaguar ford |
首先,我们为每个文件编号:
| 索引 | 文件名 |
| ---- | ---- |
| 1 | /home/dogs |
| 2 | /home/animals/cats |
| 3 | /home/cars |
然后,创建一个单词与文件索引的对照表:
| 单词 | 文件索引 |
| ---- | ---- |
| rover | 1, 3 |
| jack | 1 |
| buster | 1 |
| winston | 1 |
| zorro | 2 |
| daisy | 2 |
| jaguar | 2, 3 |
| ford | 3 |
查询倒排索引非常简单。例如,要查找单词
buster
,可以知道它出现在文件 1(即
/home/dogs
)中。要查询
rover AND jaguar
,先分别查找
rover
(文件 1 和 3)和
jaguar
(文件 2 和 3),然后取交集得到文件 3(即
/home/cars
)。
5.2 倒排索引的数据结构
我们需要两个持久化的数据结构:
-
文件名到索引表
:在倒排索引中,文件名用整数表示,以节省空间。因为一个常见的单词可能会出现在数千个文件中,所以我们使用 DETS 表来存储文件名到索引的映射信息。
-
单词到文件索引表
:对于文件中的每个单词,我们需要记录包含该单词的文件的索引。这里我们使用文件系统来存储这些信息,例如在索引目录(如
/usr/index
)中创建以单词命名的文件,文件内容为包含该单词的文件的索引。
5.3 索引器的操作
索引器的启动通过调用
indexer:start()
开始,其定义如下:
start() ->
indexer_server:start(output_dir()),
spawn_link(fun() -> worker() end).
这个函数做了两件事:一是启动一个名为
indexer_server
的服务器(使用
gen_server
编写);二是生成一个工作进程来进行索引操作。
工作进程的代码如下:
worker() ->
possibly_stop(),
case indexer_server:next_dir() of
{ok, Dir} ->
Files = indexer_misc:files_in_dir(Dir),
index_these_files(Files),
indexer_server:checkpoint(),
possibly_stop(),
sleep(10000),
worker();
done ->
true
end.
工作进程的操作步骤如下:
1. 调用
indexer_server:next_dir()
获取下一个要索引的目录。
2. 调用
indexer_misc:files_in_dir
找出该目录中需要索引的文件。
3. 调用
index_these_files(Files)
计算这些文件的倒排索引。
4. 调用
indexer_server:checkpoint()
进行检查点操作,用于崩溃恢复。当索引完一个新目录后,通知服务器已经完成该目录的索引。如果程序崩溃或停止后重新启动,下一次调用
indexer_server:next_dir()
会从下一个目录继续索引。
每次索引循环结束后,工作进程会调用
possibly_stop()
检查是否有停止调度。如果没有,则休眠一段时间后继续工作。
实际的索引操作在
index_these_files
函数中进行,这里使用了 MapReduce 实现并行计算:
index_these_files(Files) ->
Ets = indexer_server:ets_table(),
OutDir = filename:join(indexer_server:outdir(), "index"),
F1 = fun(Pid, File) -> indexer_words:words_in_file(Pid, File, Ets) end,
F2 = fun(Key, Val, Acc) -> handle_result(Key, Val, OutDir, Acc) end,
indexer_misc:mapreduce(F1, F2, 0, Files).
handle_result(Key, Vals, OutDir, Acc) ->
add_to_file(OutDir, Key, Vals),
Acc + 1.
add_to_file(OutDir, Word, Is) ->
L1 = map(fun(I) -> <<I:32>> end, Is),
OutFile = filename:join(OutDir, Word),
case file:open(OutFile, [write,binary,raw,append]) of
{ok, S} ->
file:pwrite(S, 0, L1),
file:close(S);
{error, E} ->
exit({ebadFileOp, OutFile, E})
end.
5.4 运行索引器
运行索引器的命令如下:
1> indexer:cold_start().
2> indexer:start().
...
N> indexer:stop().
执行
indexer:stop()
时,会调度停止操作,然后确认停止。
6. 索引器代码与改进方向
索引器的代码位于
indexer
目录中,包含九个文件,总代码量约 1200 行。这些文件的命名遵循常见的 Erlang 应用程序分发约定,有一个主模块
indexer.erl
和多个子模块
indexer_XXXX.erl
。
这种命名方式有优点也有缺点。优点是使用命名空间约定可以独立开发代码,无需担心代码共享问题;缺点是公共库代码可能会有不同的名称和版本,实际上应该进行合并。
为了将这个简单的索引器改进为功能齐全的索引器,我们需要进行以下改进:
-
改进单词提取
:这可能是最需要投入大量精力的部分。不同的文件类型(如 Erlang、PDF、TXT、C、Java 等)需要不同的分析技术来提取相关单词,并且需要针对所有主要的自然语言和拼写进行处理。
-
改进 MapReduce 以处理大数据集
:当前的 MapReduce 实现中,键值合并步骤在内存中运行,没有使用磁盘存储。对于元素数量非常大的数据集,我们需要仔细考虑如何表示这些值集合。
-
改进倒排索引的数据结构
:目前倒排索引的数据结构仅使用文件系统作为数据存储,使用单个 DETS 表存储文件名到索引的映射。这种技术在处理全球所有机器的文件名时扩展性较差,可能需要使用分布式哈希表。
尽管这个索引器程序比较复杂,但它的结构简单易懂,具有启动、停止程序和错误恢复的策略,通过 MapReduce 实现了搜索的并行化,并且尝试从文件中提取单词。在实际应用中,我们可以借鉴这些技术,利用简单的无副作用高阶函数来进行多核 CPU 编程。
小消息,大计算及MapReduce索引实践(续)
7. 索引器代码文件详解
索引器的代码由多个文件组成,每个文件都有其特定的功能,下面对这些文件进行详细介绍:
| 文件名 | 功能 |
| ---- | ---- |
|
indexer.erl
| 主程序,导出
start()
、
stop()
和
cold_start()
函数,是用户与索引器交互的主要接口。 |
|
indexer_porter.erl
| 实现词干提取算法,使用 Porter 算法将单词还原为相同的基础或根形式,以减少索引中的单词数量。 |
|
indexer_server.erl
| 使用
gen_server
构建的服务器,负责管理全局数据,如要索引的目录名称和索引进度等。 |
|
indexer_filenames_dets.erl
| 处理文件名到索引的映射,是
lib_filenames_dets
的副本。 |
|
indexer_checkpoint.erl
| 实现检查点机制,将数据结构存储在磁盘上,以便在程序崩溃时进行恢复。 |
|
indexer_trigrams.erl
| 类似于
lib_trigrams.erl
,对单词进行三元组分析。 |
|
indexer_misc.erl
| 包含各种杂项例程,其中包括
mapreduce
的副本。 |
|
indexer_words.erl
| 从文件中提取单词,并调用相关例程对单词进行三元组分析和词干提取。 |
|
indexer_dir_crawler.erl
| 由
indexer_server
调用的可重入目录列表程序,用于遍历目录。 |
在实际开发中,这种将功能模块化的方式有助于代码的维护和扩展。每个文件专注于一个特定的功能,使得代码结构更加清晰。
8. 并行计算的流程图示例
下面是一个使用 mermaid 绘制的简单并行计算流程图,展示了
mapreduce
的基本流程:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(输入列表 L):::process --> B(生成映射进程):::process
B --> C{映射进程 F1}:::process
C --> D(发送 {Key, Value} 消息):::process
D --> E(归约进程):::process
E --> F{合并相同 Key 的 Value}:::process
F --> G(调用归约函数 F2):::process
G --> H(输出结果 Acc):::process
这个流程图清晰地展示了
mapreduce
的工作流程:首先输入一个列表
L
,然后为列表中的每个元素生成一个映射进程,每个映射进程将处理结果以
{Key, Value}
消息的形式发送给归约进程,归约进程合并相同键的值,最后调用归约函数
F2
得到最终结果。
9. 索引器工作流程总结
索引器的工作流程可以总结为以下几个主要步骤:
1.
启动阶段
:
- 调用
indexer:start()
函数,该函数会启动
indexer_server
服务器,并生成一个工作进程。
2.
工作进程循环
:
- 工作进程调用
indexer_server:next_dir()
获取下一个要索引的目录。
- 调用
indexer_misc:files_in_dir
找出该目录中需要索引的文件。
- 调用
index_these_files(Files)
计算这些文件的倒排索引,这里使用了
mapreduce
实现并行计算。
- 调用
indexer_server:checkpoint()
进行检查点操作,用于崩溃恢复。
- 调用
possibly_stop()
检查是否有停止调度,如果没有,则休眠一段时间后继续循环。
3.
停止阶段
:
- 调用
indexer:stop()
函数,调度停止操作并确认停止。
下面是一个 mermaid 流程图,展示了索引器工作进程的详细流程:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(启动工作进程):::process --> B{是否停止调度?}:::process
B -- 否 --> C(获取下一个目录):::process
C --> D(获取目录中的文件):::process
D --> E(计算倒排索引):::process
E --> F(检查点操作):::process
F --> G{是否停止调度?}:::process
G -- 否 --> H(休眠):::process
H --> B
B -- 是 --> I(停止工作):::process
G -- 是 --> I
这个流程图清晰地展示了索引器工作进程的循环过程,包括获取目录、索引文件、检查点操作和停止调度的检查。
10. 总结与展望
通过对并行计算、
mapreduce
和全文索引的学习,我们了解了如何利用简单的高阶函数和并行编程技术来提高程序的性能和扩展性。在多核 CPU 环境下,合理使用并行计算可以显著提升计算效率。
mapreduce
作为一种强大的并行编程模型,能够有效地处理大规模数据,并且可以通过调整调度器数量等方式进一步优化性能。全文索引的实现则展示了如何利用
mapreduce
来构建一个简单的索引器,并且通过倒排索引的数据结构来提高查询效率。
然而,目前的索引器还存在一些不足之处,需要进一步改进。例如,单词提取的准确性和效率需要提高,
mapreduce
需要更好地处理大数据集,倒排索引的数据结构需要更具扩展性。未来的研究可以集中在这些方面,以构建一个更加高效、功能齐全的索引器。
同时,随着计算机硬件技术的不断发展,多核 CPU 和分布式系统的性能将不断提升,我们可以期待并行计算和
mapreduce
等技术在更多领域得到应用,为解决大规模数据处理问题提供更强大的支持。
总之,并行计算和
mapreduce
是非常有前途的技术,通过不断地学习和实践,我们可以更好地利用这些技术来解决实际问题。
超级会员免费看

49

被折叠的 条评论
为什么被折叠?



