基本介绍
NativeTask 是 Hadoop MapReduce 的高性能 C++ API 和运行时。为什么叫 NativeTask 是因为它是一个只专注于数据处理的原生计算单元,这正是 Task 在 Hadoop MapReduce 上下文中所做的事情。换句话说,NativeTask 不负责资源管理、作业调度和容错。这些都像以前一样由原始 Hadoop 组件管理,没有改变。但实际的数据处理和计算,消耗了大部分集群资源,都委托给了这个高效的数据处理单元。
NativeTask 的设计速度非常快,带有原生 C++ API。所以更高效的数据分析应用程序可以建立在它之上,比如谷歌的 Tenzing 中提到的基于 LLVM 的查询执行引擎。其实这是 NativeTask 的主要目标,提供一个高效的原生 Hadoop 框架,因此可以在其上构建更高效的数据分析工具:
-
使用并行 DBMS 中现有的最先进的查询执行技术的数据仓库工具,例如压缩、向量化、动态编译等。这些技术更容易在Native code中实现,因为这些技术中的大多数都是使用C/C++实现,像Vectorwise, Vertica等
-
高性能数据挖掘/机器学习算法大多数是 CPU 密集型的,涉及大量数值计算,大多使用Native Code实现,本地运行时具有更好的性能,并且很容易将这些算法移植到 Hadoop;
从用户的角度来看,NativeTask 很像 Hadoop Pipes:使用 NativeTask 库中提供的头文件和动态库,您将应用程序或类库编译为动态库而不是可执行程序(使用 JNI),然后使用 Submitter 工具像流式传输或管道一样将您的工作提交到 Hadoop 集群。
优势特性:
- 为Hadoop 集群提供高性能、更具成本效益;
- 使用C++ API实现Java 语言不可或不方便的开发的更有效优化,如 SSE/AVX 指令、LLVM、GPU 计算、协处理器等。
- 支持无排序,通过去除排序方式消除shuffle阶段性能瓶颈,产生更好的数据处理吞吐量;
- 支持 foldl 风格的 API,聚合查询更快;
- 基于二进制的 MapReduce API,没有序列化/反序列化开销;
- 适配Hadoop 0.20-0.23版本(需要task-delegation patch)
为什么NativeTask会快?
这是人们最感兴趣的话题,但在解释 NativeTask 的技术细节之前,更合适的问题应该是:
Hadoop 够快吗?
实际上,Hadoop并不会很快。通常可以看到一个编写良好的 C++ 程序只需几秒钟即可处理 1GB 数据,但处理相同的数据可能需要 MapReduce 任务几分钟,而且许多研究表明 Hadoop MapReduce 效率与传统并行 DBMS 相比也不高。
另一方面,Hadoop 在可扩展性和容错方面做得更好。虽然效率不够,但我相信 Hadoop 获得与手写原生程序相同的性能没有任何技术限制。
所以:它可以有多快?
下面来做一些计算:示例,使用一台商用服务器:
戴尔 PowerEdge C2100
CPU:2*6核至强5600
内存:48GB
磁盘:12 * 2TB SATA
该服务器可以并行运行 12 个任务,每个任务使用 1 个核(2 个线程)、4GB 内存、1 个 SATA 磁盘。典型的Map任务数据流及其理想速度是:
从 HDFS 读取数据 : 100MB/s(数据本地任务)
解压 : 700-2000MB/s(snappy或lz4)
RecordReader+Mapper : 2000MB/s (LineRecordReader+IdenticalMapper)
排序 : 300-600MB/s(变化很大,Key/Value越大会更快)
压缩 :250-500MB/s(变化很大,取决于数据类型)
写入本地磁盘 :100MB/s(2000MB/s 使用PageCache)
这里需要注意的一点是,启用轻量级压缩后,磁盘不再是瓶颈,系统吞吐量越来越取决于 CPU 成本。
所以如果一切都很完美,一个Map任务应该处理 1GB(250MB 压缩)数据:
Read + Decompression 2.5s
RecordReader+Mapper 0.5s
Sort 2s
Compression+Write 3s
Total 8s
所以它是 1GB/8s = 125MB/s。此外,对于选择+过滤+连接/聚合查询,不需要排序,输出大小远小于输入大小,每个核心有2个线程用于一个任务,综合这些因素,可以处理1GB的数据只需 3 秒,大约 333MB/秒。对于整个服务器,它是 12 * 333MB/s = 4GB/s。这意味着在最佳条件下(完全平衡的调度、完美的数据局部性、没有慢速节点或故障),具有 10GbE 的 25 节点集群应该:
58秒完成1TB Terasort(27s map + 10s shuffle + 21s reduce),如果输入、map输出、最终输出全部压缩(Terasort默认是IO测试,不允许压缩,但可以作为典型的MapReduce框架测试)。
在 10 秒内完成针对 1TB 数据集的简单聚合查询。
当然,上面的论点中有很多假设,但是在整个处理流程的每个阶段都没有技术限制。有了这种处理吞吐量,与商业数据仓库解决方案相比,可以以非常低的成本设置基于 Hadoop 的数据仓库,但性能相当。上面提到的服务器每节点成本约10-20K$,容量为8TB(3replicaion)/24T(解压),即每核1-2K$,1-2K$/TB。随着硬件成本的不断下降,这个成本也会不断下降。
虽然这听起来很神奇,但要到达那里还有很长的路要走。目前,一个编写良好的 Hadoop map 任务可以在大约 40-120 秒内处理 1GB 的数据,因此它是 10-30MB/s,Hive/Pig 任务可能需要更长的时间,因为它们的高级抽象。显然它远非最大可能的速度(100-300MB/s)。这就引出了下一个问题:
为什么 Hadoop 性能不够好?怎么提高?
以下是一些主要原因(但不是全部):
1. I/O 瓶颈
大多数 Hadoop 工作负载都是数据密集型的,因此如果没有对输入、中间输出和输出使用压缩,I/O(磁盘、网络)可能会成为瓶颈。
解决I/O瓶颈方式是使用压缩。目前Hadoop已经支持多种高性能通用轻量级压缩算法:snappy 和 lz4,具有 2x-5x 压缩比(实际上对于 Haodop 工作负载数据类型要高得多),I/O 带宽实际上是实际 I/O 带宽的 2x-5x。
还有一点需要提到的是高速网络,现在的服务器比几年前强大了很多,每个节点的核心和RAM越来越多,一台服务器可以同时运行更多的任务,所以像10~40GbE这样的高速网络将成为标准对于 Hadoop 集群的设置,当前的 Hadoop 网络堆栈(基于 jetty/netty)是否能够承受如此大的吞吐量也是值得怀疑的。
2. 执行效率低下。这种低效率无处不在:
- Map侧排序:当前排序可能比编写良好的排序慢 10 倍,因为当前排序实现存在缓存局部性问题并且不是基于分区的。这可能会在最新的 Hadoop 版本中得到改进,但仍然不是最佳的。
- 序列化/反序列化:这会导致不可避免的对象创建、大量的小缓冲区副本、繁重的流抽象、原始类型装箱/拆箱、次优比较操作等。Ser/Deser 在 MapRedcue 框架级别和查询执行级别(Hive/ Pig),这是Hadoop数据处理吞吐量差的主要原因。很久以前就有讨论过,但还没有进展。这是我的想法:在 MR 框架级别,纯二进制接口对于构建在其上的查询执行引擎来说已经足够且高效,甚至更激进:不要使用 MR API,只需使用任务输入拆分和数据重新分配实用程序(shuffle)由 MR 框架提供;在查询执行层面,ser/deser 也不是必须的,最有效的方法是使用某种 schema 来描述数据,使用 C struct 类似二进制表示来存储数据,然后使用 LLVM 直接生成基于 schema 的原生代码和逻辑查询计划。这可以大大提高处理吞吐量,谷歌报告说在 Tenzing 中使用 LLVM 可以提高 6 到 12 倍的吞吐量。
- Shuffle:Hadoop 0.23对shuffle做了很多优化(netty、batch fetch等),但还可以进一步优化(比如最新Hadoop版本的shuffle还是比百度内部版本慢)。当不需要排序时,可以利用更多优化。当然,要充分利用高速以太网,也需要进行大量的调整工作。
- 数据局部性。这是并行 DBMS 优于 Hadoop 的主要优势之一,具有先进的数据分区、索引和复杂的查询计划,大多数数据在本地处理,数据移动减少到最低限度。 Hive 已经做了一些类似的优化,但可以做的更多,还有一些优化需要 MapReduce 之外更灵活的计算模型。
- 调度和启动开销。这对小型作业和多次迭代作业有很大影响。
3. 不灵活的编程范式
MapReduce 是一种非常通用的数据处理模型,这赋予了它力量,但也限制了它的性能。对于一些特定的任务,可以采用更有效的方法。 Tenzing 论文中有很多例子,最近也有很多关于提高 MapReduce 查询性能的研究。 Hive 在应用层面做了很多优化,但是还需要一些框架层面的优化/接口,比如对聚合查询没有排序的 hash-aggregation,map-side join 和 dictionary-server,chained MapReduce job(结合 reducer 和 mapper 的下一个 MR 工作)等。
聚合查询没有排序的聚合,与字典服务器的映射端连接,链式 MapReduce 作业(将减速器与下一个 MR 作业的映射器结合起来)等。
下列因素直接决定了 NativeTask 的设计原则:
1. Native实现
java是非常高效的,实际上根据经验,java对于普通任务来说是非常高效的,并且java有一些c/c++难以实现的运行时优化技术。例如,在 C++ 中做锁粗化、虚函数内联等动态优化是非常困难的。但是有一些任务/优化,我认为对于这个项目来说是必不可少的,最好在Native运行时完成:
- 压缩:几乎所有最快的压缩算法都是用Native代码编写的,目前 Hadoop 使用 JNI 以批量处理的方式调用这些库,但仍然存在一些跨越 JNI 边界的开销,尤其是在解压缩速度非常快(>1GB/s)时。而一些技术,如延迟解压(Lazy Decompression)、对压缩数据的直接操作等,无法适应批量处理。
- SSE/SIMD :这类似于压缩,目前 Hadoop 使用 JNI 来利用 SSE 优化,例如 CRC 校验和。但同样,这不是一个通用的解决方案。
- LLVM: 如前所述,该项目的主要目标之一是提供原生运行时以支持高级查询执行引擎,几乎可以肯定会使用 LLVM。因为 LLVM 是原生的 C++ 库,所以 C++ 更适合。
2. 避免序列化和内存拷贝
如前所述,序列化有很多开销。为了获得最大的吞吐量,最好放弃序列化,或者引入一些
序列化方法,可以直接对序列化数据进行操作,或者避免对象创建和内存复制。同样,它在 java 中很难或用户友好,但在Nativ e代码中方便且直接,例如 C struct 之类的数据表示。另外,当整个数据流在native端(CRC校验、解压、读取、进程、写入、压缩、CRC校验)时,可以而且应该消除大量的小内存副本。所以接口和底层处理流程都是为了尽量消除大部分内存拷贝而设计的。
3. 把事情简单化。
这个项目主要关注纯数据处理,不像典型的分布式系统,不应该涉及太多复杂的东西,比如多线程编程和同步,高级抽象或复杂的系统编程。例如,这个项目试图避免当前 MapReduce 设计中存在的异步输出收集器、io 流抽象和其他复杂的东西。
4. 更少的兼容性问题。
如前所述,该项目的主要目标是在此基础上构建高级数据分析工具/库,兼容性应限制在更高级别(例如查询语言级别),同时允许在较低级别具有更大的灵活性,所以我们可以对此进行各种实验。新的 MRv2/YARN 框架允许我们试验新的框架。最后,由于这个项目处于非常早期的阶段,很多事情在开发过程中肯定会发生根本性的变化。
设计及实现
NativeTask 由两个主要部分组成:java 端和native 端。 Java端负责绕过正常的java数据流,将数据处理委托给native端,由native端进行实际计算。 Java 端和native端使用 JNI 以同步、批处理(基于块)的方式相互通信。这与 Streaming 和 Pipes 中使用的其他 IPC 机制不同。套接字和管道对于数据处理来说已经足够快了,但是它们会消耗大量的 CPU,并且会引入多线程编程和异步处理。
任务委派
为了绕过正常的java数据流,NativeTask引入了任务委托接口,它将绕过逻辑插入到MapTask和ReduceTask的开头(需要修改当前的MapReduce源代码)。绕过逻辑会检查JobConf中是否配置了delegator,如果有则使用配置的delegator运行任务,绕过原有的逻辑。委托接口如下所示:
-
MapTask:
void run(TaskAttemptID taskID, JobConf job, TaskUmbilicalProtocol umbilical, DelegateReporter reporter, Object split)
- ReduceTask:
void run(TaskAttemptID taskID, JobConf job, TaskUmbilicalProtocol umbilical, DelegateReporter reporter, RawKeyValueIterator rIter)
MapTask需要拆分信息,目前原生RecordReader只支持FileSplit。对于ReduceTask,shuffle和merge仍然在java端进行,所以RawKeyValueIterator被传递给delegator。 shuffle 和 merge 的原生实现在未来肯定会有更好的性能。我提出了另一种可能的(更通用的)解决方案 Extensible Task(MAPREDUCE-3246) 来尝试使任务可扩展,但在实践中我发现委托接口更方便,因为仍然有很多工作无法在原生端完成现在。无论如何,这些都是小问题,因为两者都很容易重构。
目前委托支持两种数据流模式:
1. Native Mapper/Reducer only:与现有的 InputFormat/OuputFormat 和 RecordReader/Writer 兼容,Key/Value 对被批量传入/传出原生端。
典型 MapTask 的数据流:
RecordReader -> Serialize -> [DirectByteBuffer] -> Native Mapper -> Native Output Collector(Sort & Spill)
一个典型的 ReduceTask 的数据流:
RawKeyValueIterator -> [DirectByteBuffer] -> Native Reducer -> [DirectByteBuffer] -> 反序列化 -> RecordWriter
2. Native Mapper/Reducer 和 Native RecordReader/Writer:目前 InputFormat/OutputFormat 仍然存在用于输入拆分和输出提交,但是 RecordReader/Writer 是 native 的,因此 native 任务可以直接实现 RecordReader/Writer 用于读取输入或写入输出,从而产生更好的性能和灵活性。
典型 MapTask 的数据流:
Input Split -> Native RecordReader -> Native Mapper -> Native Output Collector
一个典型的 ReduceTask 的数据流:
RawKeyValueIterator -> [DirectByteBuffer] -> Native Reducer -> Native RecordWriter
小批量处理
如前所述,java 端和 native 端以基于块的批处理模式传递序列化的 K/V 数据,而不是基于记录。这是因为 JNI 调用有相当大的开销,批处理可以最大限度地减少 JNI 调用的数量。块大小约为 32KB~128KB,小于 L2-cache。
基于 JNI 的批处理是在 Java 类 NativeBatchProcessor 和原生 C++ 类 BatchHandler 中实现的,JNI 的东西在这两个类中是隔离的,所以项目的其他部分不需要处理 JNI 的复杂性。
类库
C++ 的一个问题是它缺少反射机制,因此很难在客户端在 JobConf 中设置 mapper、reducer、record reader、writers 并在任务中动态创建它们。 Pipes 使用静态链接,与 Pipes 不同,NativeTask 使用更动态的东西,基于类库的结构。一个基于 NativeTask 的典型应用程序由几个动态库(作为类库)组成,例如:
[Task JVM]
|
delegation
|
|--load-> [libnativetask.so]
|--load-> [userlibrary.so]
|--load-> [application.so]
|
create native objects
|
run mapper/reducer
|
|----------------|
done()
NativeTask 使用一些模板技巧来实现一个非常简单的等效于 Hadoop 的 ReflectionUtils.newInstance()。将 .so 库视为类库(如 .jar 文件),每个 .so 库都有一个入口函数来创建该库中类的 C++ 对象。动态库 libnativetask.so 是 NativeTask 运行时,但它也作为一个类库,带有一些预定义的 Mapper/Reducer、Partitioner 和 RecordReader/Writer,例如 IdentitcalMapper/Reducer、HashPartitioner、TotalOrderPartitioner、LineRecordReader/Writer、等等。
动态链接的缺点是 C++ 的 ABI 兼容性较差,但由于这是一个开源项目,主要针对 Linux 和同构计算环境,根据我在 HCE(Hadoop C++ Extension) 方面的经验,这并不严重问题。
IO 缓冲区和压缩
为了尽量减少缓冲区复制,引入了两个轻量级 io 缓冲区:ReadBuffer 和 AppendBuffer,它们不同于基于装饰器模式的 java 和 Hadoop IO 流,ReaderBuffer 和 AppendBuffer 被实现为内联最频繁调用的方法,并添加代码路径以避免一个缓冲区支持压缩/解压缩时复制。这并不意味着 NativeTask 不使用基于装饰器的流,而是它们仅用于批处理模式,例如文件读/写和 CRC 校验和。
在Native代码中添加压缩编解码器要容易得多,目前 snappy、lz4 和 gzip 已集成到 NativeTask 中。
任务数据流
map/reduce 任务的数据流和主要逻辑与原始实现几乎相同,不同之处在于实现细节。一般的区别在于,原生实现更简单,因此易于优化,而映射器/归约器、读取器/写入器 API 旨在使零拷贝成为可能。
MapOutputCollector
这部分贡献了很多性能提升。如前所述,当前 Hadoop 的排序实现并不理想。因此使用了不同的基于分区的排序和溢出方法。此方法的主要组件如下所述:
基本上,map output collect 是一个分区键/值缓冲区,mapper 发出键/值对,然后使用分区器生成一个分区号,map output collect 找到一个 PartitionBucket 来放置这个键/值对,一个 PartitionBucket 有一个数组MemoryBlocks 来保存 KV 对,如果最后一个
MemoryBlock 已满,它会从 MemoryPool 中分配一个新的 MemoryBlock,如果 MemoryPool 中没有足够的内存,就会激活溢出。
MemoryPool 保存大小为 io.sort.mb 的缓冲区,并跟踪当前缓冲区的使用情况,注意如果内存没有实际访问,这个缓冲区将只占用虚拟内存而不是 RSS(真正使用的内存),这比 java 好,因为 java initialize数组。
MemoryBlock 是由 MemoryPool 支持的一小块内存块,供 PartitionBucket 使用。 MemoryBlock的默认大小等于ceil(io.sort.mb / partition / 4 / MINBLOCKSIZE) * MINBLOCKSIZE,目前MINBLOCKSIZE等于32K,MemoryBlock的最大大小为1M,应根据分区号和io.sort动态调整.mb 未来。 MemoryBlock 的目的是减少 CPU 缓存未命中。在对较大的间接寻址的 KV 对进行排序时,排序时间会以 RAM 随机读取为主,所以使用 MemoryBlock 让每个桶获得相对连续的内存。
PartitionBucket 存储分区的 KV 对,它有两个数组: 向量块 此桶使用的块向量偏移量 KV 对在 MemoryPool 中的起始偏移量 此向量尚未受内存控制(在 io.sort.mb 中),但实际上它没有'不要过多地影响内存占用。
这种方式在partition number & Key/Value size很大的时候效果不好,但是这种情况比较少见,可以改进一下,比如如果io.sort.mb/partition数太小了,可以直接使用MemoryPool(禁用MemoryBlock)。
Map端排序
由于map输出缓冲区是分区的,我们可以对每个分区分别进行排序,这与java的单缓冲区方式不同。通过这样做,排序可以快得多,因为对大数组进行排序比对许多小数组进行排序要慢得多;小数组也意味着更少的缓存未命中;并且分区号不需要在排序中进行比较。我的测试显示排序性能提高了 10 到 20 倍。
目前只支持二进制比较器,因为它是高效的,并且对于大多数应用程序来说已经足够了,固定长度键比较和用户定义的比较功能可能有用,它们可以在未来实现。
无排序数据流
NO sort dataflow 在原生 map 端很容易实现,只是不要对每个 PartitionBucket 进行排序,因为 combiner 依赖于将 KV 对分组在一起,所以在 no sort dataflow 中不支持 combiner,但是在很多情况下可以在 mapper 逻辑中进行 combiner .本来我打算实现支持组合器的分组数据流,但是在排序优化之后,支持分组似乎没有什么好处。
由于reduce端shuffle和merge还没有实现,所以reduce端没有排序数据流在java中实现。将带有 map 和 reduce 端实现的补丁提交给 MAPREDUCE-3246。
并行化Spill
由于 map 输出 KV 缓冲区是分区的,并行排序和溢出成为可能,但这需要对原始 Hadoop 代码进行一些更改,所以我没有实现它。例如,假设一个reducer编号为100的map任务,我们不是溢出到一个文件,而是溢出到一个目录:输出|- partition0-49.out |_ partition50-100.out 然后排序、合并、溢出、压缩可以全部并行完成,充分利用CPU资源,减少任务执行时间。
Reduce任务
洗牌和合并还没有实现,所以没有什么特别的。在 combiner 和 reducer 阶段引入了 2 个新接口,因此您可以在 combiner 和/或 reducer 阶段使用映射器或文件夹接口。这两个接口都是被动接口,适合在无分类数据流中实现聚合式工作负载。 Mapper API 适用于希望自己管理哈希表的用户,Folder API 适用于希望框架为他们管理哈希表的用户。这项工作是实验性的,尚未完成。
可用性和其他
为了提高可用性,NativeTask 库中内置了几个类: LineRecordReader/LineRecordWriter IdenticalMapper/IdenticalReducer HashPartitioner TotalOrderPartitioner 将添加更多 Reader/Writers,以支持其他 Input/OutputFormats,例如 SequenceFile 和 RCFile。
我还实现了与 NativeTask 库捆绑的 Terasort 和 Wordcount,以简化性能测试。
“example”目录下有一个例子,一个简单版本的Hadoop Streaming,用来说明一个比较复杂的demo。
与Java相比,C++中缺少相当多的实用程序类,我必须重新实现它们,例如同步实用程序、进程和管道、随机生成器等。其中一些是基于JDK和google-leveldb复制和修改的。
这个项目使用了很多google的开源项目:snappy、gtest、cityhash、leveldb,未来可能会用到sparsehash来实现hash聚合。另一个项目是 LZ4,它的简单性和惊人的速度给我留下了深刻的印象。
性能实验
我在一个 15 个节点的集群上使用简单的 MapReduce 应用程序测试了 hadoop-1.0 和 NativeTask:Terasort 和 WordCount。
集群配置
测试集群有 16 个节点通过 1Gb 以太网连接,每个节点有:
CPU: Xeon(R) CPU E5645 * 2, 2.4GHz, 12 core, 24 thread
Memory: 32GB
Disk: 12 * 1T SATA
JDK: 1.6 u23
Map Task: 7
Reduce Task: 7
我使用带有任务委派补丁的 Hadoop 1.0 版。 namenode 和 jobtracker 部署在 save 节点上,datanodes 和 tasktracker 部署在其他 15 个节点上。所以整个集群有105个map slot,105个reduce slot。块大小配置为 256MB。
NativeTask 库是由 gcc 版本 3.4.5 编译的,因为它是测试环境中唯一可用的编译器,这个编译器很老,可能会生成不好的原生代码。实际上在我自己的电脑 Macbook Pro 上使用 gcc 版本 4.2.1(Apple Inc. build 5659),结果要好得多(快 50%-70%),我电脑的 CPU 是 Intel Core i5 2.3GHz,应该有性能与至强 E5645 相似。无论如何,我建议任何有兴趣编译代码并在他们自己的环境中运行的人,并让我知道。我认为我最近没有资源和时间进行大规模测试:(
测试应用
Standard Terasort 实际上是一个 IO 测试,不允许压缩,但是为了这个实验的目的,为了评估数据处理吞吐量,在输入、中间输出和最终输出中使用了 snappy 压缩,这实际上从磁盘上移开了瓶颈和网络 IO 到 CPU。本次测试侧重于纯框架性能,key/value直接在mapper和reducer中传递,没有对象的创建和复制。
WordCount 是一个简单的聚合工作负载,它们是应用程序级别的一些计算。最初的 WordCount 演示实现效率低下,涉及大量类型转换、对象创建和复制。我在 NativeTask 中使用相同的实现制作了一个优化版本,两个测试结果都将包含在内。
以下是 terasort 和 wordcount 的一些特性:
Terasort | WordCount | |
---|---|---|
Key value size | 100 | 8-16 |
Combiner | No | Yes |
Input | 200G(44G compressed) | 100G(52G compressed) |
MapTask | 200(1G/task) | 200(500M/task) |
ReduceTask | 200 | 100 |
Compression Ratio | about 0.2 | about 0.5 |
Input/Output | 1:1 | 1:0(almost) |
测试数据生成
数据生成命令:
Terasort:
bin/hadoop jar hadoop-examples-1.0.1-SNAPSHOT.jar teragen 2000000000 /tera200G-snappy
wordcount:
bin/hadoop jar hadoop-examples-1.0.1-SNAPSHOT.jar randomtextwriter -Dtest.randomtextwrite.totalbytes=100000000000 -Dtest.randomtextwrite.bytesper_map=500000000 -outFormat org.apache.hadoop.mapred.TextOutputFormat /text100G-snappy
测试执行命令:
Terasort Java
bin/hadoop jar hadoop-examples-1.0.1-SNAPSHOT.jar terasort /tera200G-snappy /terasort200G-java
Terasort NativeTask
bin/hadoop jar lib/hadoop-nativetask-0.1.0.jar terasort /tera200G-snappy /terasort200G-nt
WordCount Java
bin/hadoop jar hadoop-examples-1.0.1-SNAPSHOT.jar wordcount /text100G-snappy /wordcount-100G-java
WordCount Java Optimized
bin/hadoop jar hadoop-examples-1.0.1-SNAPSHOT.jar wordcount -Dwordcount.enable.fast.mapper=true /text100G-snappy /wordcount-100G-java-opt
WordCount NativeTask
bin/hadoop jar lib/hadoop-nativetask-0.1.0.jar -reader NativeTask.LineRecordReader -writer NativeTask.TextIntRecordWriter -mapper NativeTask.WordCountMapper -reducer NativeTask.IntSumReducer -combiner NativeTask.IntSumReducer -input /text100G-snappy -output /wordcount-100G-nt
测试结果
erasort
Terasort 200G(io.sort.mb=1200M, no merge) 200Map,200Reduce | Total Time(s) | Map Avg(s) | Map Best(s) | Sort(s) | Shuffle Avg(s) | Shuffle Best(s) | Reduce Avg(s) | Reduce Best(s) | Map CPU(ms) | Reduce CPU(ms) | Map Memory(M) | Reduce Memory(M) |
---|---|---|---|---|---|---|---|---|---|---|---|---|
java | 220 | 51 | 47 | 23.336 | 31 | 20 | 20 | 14 | 10357020 | 11466330 | 292001 | 338160 |
native | 139 | 15 | 14 | 3.476 | 30 | 20 | 17 | 11 | 295510 | 10595440 | 259581 | 336060 |
ratio | 1.583 | 3.4 | 3.36 | 6.71 | 1.03 | 1 | 1.176 | 1.273 | 3.504 | 1.082 | 1.125 | 1.006 |
WordCount
WordCount 200G(io.sort.mb=300M) 200Map, 100Reduce | Total Time(s) | Merge Segments | Map Avg(s) | Map Best(s) | Sort(s) | Shuffle Avg(s) | Shuffle Best(s) | Reduce Avg(s) | Reduce Best(s) | Map CPU(ms) | Reduce CPU(ms) | Map Memory(M) | Reduce Memory(M) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
java | 266 | 5 | 124 | 117 | 45 | 8 | 8 | 1 | 1 | 25324990 | 410990 | 211082 | 21153 |
java optimized | 243 | 5 | 112 | 95 | 46 | 8 | 8 | 1 | 1 | 22909200 | 412430 | 104078 | 21054 |
native | 55 | 4 | 17 | 16 | 5.52 | 8 | 8 | 1 | 1 | 3287460 | 443890 | 104350 | 21706 |
ratio | 4.42 | - | 6.59 | 5.93 | 8.33 | 1 | 1 | 1 | 1 | 6.869 | 0.939 | 0.997 | 0.970 |
结果分析
MapTask
map 任务有很多性能提升,这是因为它都是原生的,并且它有一个相对高效的排序和溢出实现。 WordCount 的加速比 Terasort 高,这是因为 terasort 的 KV 大小比 wordcount 大得多,因此相同的输入量在 WordCount 中处理的记录更多,框架对每条记录有一些恒定的开销,并且排序性能与记录数有关,所以记录越小,或者记录越多,NativeTask 的速度优势就越大。
ReduceTask
Reduce端确实变化很大,在 Terasort 测试用例中大约 8%。这是因为reduce侧shuffle和merge仍然是在java中完成的,shuffle和merge在reduce任务中占用了大部分CPU资源和任务执行时间;并且在跨越 JNI 边界时会有额外的序列化开销。在实施 shuffle 和 merge 之后,或者可能只是合并之后,预计会有类似(可能更小)的性能提升。
如前所述,在 hadoop-1.0 中 shuffle 的实现是次优的,虽然目前的 trunk 版本已经对 shuffle 性能有了很大的提升,但还是可以优化的。最后,这个测试环境只使用了 1GbE 网络,如果使用像 10GbE 这样的高速网络,我们可以获得更好的整体工作加速。
编译器因素
正如我之前所说,实验中使用的 NativeTask 库可能不是最理想的。例如,本机 wordcount 任务 unittest 在我的笔记本电脑上运行大约 11 秒,在测试环境中运行 16 秒,本机 terasort 任务 unittest 在我的笔记本电脑上运行大约 9 秒,在测试环境中运行 14 秒。以下是测试生成的一些日志:
在我的笔记本电脑上:
12/01/04 17:35:30 INFO Native Mapper with MapOutputCollector, RecordReader: NativeTask.LineRecordReader Combiner: NativeTask.IntSumReducer Partitioner: default
12/01/04 17:35:33 INFO Spill 0 [0,100) collect: 1.515s sort: 1.192s spill: 0.227s, record: 12841142, key: 1000, block: 400, size 17855, real: 18895
12/01/04 17:35:36 INFO Spill 1 [0,100) collect: 1.226s sort: 1.154s spill: 0.223s, record: 12778865, key: 1000, block: 400, size 17855, real: 18907
12/01/04 17:35:39 INFO Spill 2 [0,100) collect: 1.463s sort: 1.167s spill: 0.224s, record: 12748890, key: 1000, block: 400, size 17855, real: 18894
12/01/04 17:35:40 INFO Sort 3 [0,100) time: 0.699
12/01/04 17:35:41 INFO Merge 4 segments: record 0, key: 1000, size 17855, real 18958, time: 0.383
在测试环境上:
12/01/04 15:54:56 INFO Native Mapper with MapOutputCollector, RecordReader: NativeTask.LineRecordReader Combiner: NativeTask.IntSumReducer Partitioner: default
12/01/04 15:55:01 INFO Spill 0 [0,100) collect: 2.426s sort: 1.557s spill: 0.352s, record: 12841142, key: 1000, block: 400, size 17855, real: 18895
12/01/04 15:55:05 INFO Spill 1 [0,100) collect: 2.097s sort: 1.507s spill: 0.287s, record: 12778865, key: 1000, block: 400, size 17855, real: 18907
12/01/04 15:55:09 INFO Spill 2 [0,100) collect: 2.077s sort: 1.506s spill: 0.399s, record: 12748890, key: 1000, block: 400, size 17855, real: 18894
12/01/04 15:55:11 INFO Sort 3 [0,100) time: 0.951
12/01/04 15:55:11 INFO Merge 4 segments: record 0, key: 1000, size 17855, real 18958, time: 0.491
另一方面,相同的 java 任务单元测试在我的笔记本电脑和测试环境上运行的速度大致相同。所以很有可能是编译器的问题,排除这个因素,NativeTask 应该有额外的速度优势,大约 40%-60%。
结论和未来工作
一般来说,NativeTask 优于原始 MapReduce 框架,map 任务大约 3x-7x,reduce 任务 1x-1.1x,整个作业 1.5x-5x。如果编译器的假设有一定的真实性,map 任务的加速比可能是 4.5x-12x,相应的加速比应该更大。 NativeTask 高性能的主要原因是避免序列化、避免重抽象、更好地使用压缩以及 C++ 相对于 Java 的速度优势。由于这个项目处于非常早期的阶段,我预计未来会有更多的改进。如前所述,单个map任务的吞吐量有可能达到300MB/s,目前NativeTask在50-100MB/s左右,还有提升空间。 NativeTask 仅解决了 Hadoop 效率低下的某些方面,其他方面如 shuffle、数据本地化、调度和启动开销不在本项目的范围内,但可能成为某些工作负载的主导因素。这些方面最好在更高的层次上解决,例如像 hive 这样的数据仓库工具,或者像 giraph 这样的 BSP 工作负载。该项目下一步将集成无排序数据流,支持文件夹API,实现reduce shuffle和merge,并行排序和溢出。同样,该项目的主要目标是提供一个高效的原生 Hadoop 框架,因此可以在其上构建更高效的数据分析工具,并具有与商业系统相同的性能。
我正在考虑一个修改版的 hive,它将其物理查询计划转换为 LLVM IR,然后在 NativeTask 之上运行。根据 Google 的 Tenzing 论文,以及 Hive 和 NativeTask 的现状,Hive 的 10 倍加速是完全可能的,并且商业数据库中已经存在更先进的技术,有可能达到与商业数据仓库产品相当的性能。
另一个可能的方向是单个胖节点或非常小的集群的 Hadoop 分发。大多数分析工作量对于小公司来说都是 TB 规模,只有少数大公司真正需要扩展到 PB 规模,拥有多核处理器和非常密集的磁盘存储,不久的将来的商品服务器可以拥有与今天的小公司相同的计算能力和容量Hadoop集群,单个胖节点Hadoop可以执行许多分布式模式下不可能的优化。无网络瓶颈,数据可以直接共享,结合 NativeTask 的性能提升,小工作负载不再需要集群运行。未来,也许每个数据分析师都可以使用 Hadoop 仅在他们的计算机上分析 TB 的数据,如果他或她想要更多的处理能力,只需连接到云并提交您相同的 Hadoop 应用程序不变。
如果有人有类似想法并想开始开源项目或在现有项目中实现它们,请告诉我:)
有用的链接
有关矢量化或动态编译的更多信息:
* Efficiently Compiling Efficient Query Plans for Modern Hardware
* MonetDB/X100: Hyper-pipelining query execution
一篇关于未来硬件趋势和编程模型的有趣文章:
* http://herbsutter.com/welcome-to-the-jungle/
附录:
Native Code解释:已被编译为特定于处理器的机器码的代码。这些代码可以直接被虚拟机执行。与字节码的区别:虚拟机是一个把通用字节码转换成用于特定处理器的本地代码的程序;
llvm是low level virtual machine的简称,其实是一个编译器框架。llvm随着这个项目的不断的发展,已经无法完全的代表这个项目了,只是这种叫法一直延续下来。llvm的主要作用是它可以作为多种语言的后端,它可以提供可编程语言无关的优化和针对很多种CPU的代码生成功能。此外llvm目前已经不仅仅是个编程框架,它目前还包含了很多的子项目,比如最具盛名的clang。llvm这个框架目前已经有基于这个框架的大量的工具可以使用,本文不再详细赘述。
SSE/SIMD:
1) SIMD(Single Instruction Multiple Data)即单指令流多数据流,是一种采用一个控制器来控制多个处理器,同时对一组数据(又称“数据向量”)中的每一个分别执行相同的操作从而实现空间上的并行性的技术。简单来说就是一个指令能够同时处理多个数据。
2) SSE的全称是Sreaming SIMD Extensions, 它是一组Intel CPU指令,用于像信号处理、科学计算或者3D图形计算一样的应用。 其优势包括:更高分辨率的图像浏览和处理、高质量音频、MPEG2视频、同时MPEG2加解密;语音识别占用更少CPU资源;更高精度和更快响应速度。