学习笔记:MapReduce on Ray

目录

本文主要介绍:

  • MapReduce
    • 什么是MapReduce
    • Map & Reduce
  • Ray
    • 什么是Ray
    • Ray的一些简单用法
  • MapReduce on Ray
    • 简单用法
    • 实战
  • Reference

本文实验环境及相关材料:

  • macOS, Python 3.6.7 with Anaconda, Ray 0.6.0
  • 代码: https://github.com/hatuw

/*
理论上支持其他平台,只要Python的版本和Ray的版本对应即可。笔者已在macOS和Ubuntu上面进行测试,均无错误。如果读者在实现的过程中发现错误,欢迎提issue.
本文中的代码已上传至Github,文中在抄写代码的过程中可能出现纰漏,代码以Github为准。欢迎大家指正,谢谢!
*/

MapReduce

什么是MapReduce?

MapReduce最早(~2004年)是由Jeff Dean等人提出的一种面向大规模数据处理的并行计算模型和方法。源于函数式编程语言中的map和reduce内置函数,MapReduce的主要思想是Map(映射)和Reduce(规约),通常用于大规模数据集的并行运算。

关于MapReduce的原理和例子网络上有挺多优秀的文章,本文只是简单说一下MapReduce的原理和一个粗糙的例子,以便读者快速理解。个人建议可以去翻翻MapReduce的论文和相关的资料(见文末)。

Map(映射):

Map过程就是把一组数据按照某种Map映射成新的数据。如下面这个例子,就是用Python3.6中内置的map函数,将list[0, 1, 2, 3, 4]中的每个元素进行了平方的操作。(需要注意的是,Python3.x中的map返回的是迭代器,2.x返回的是列表)

# Python2.x 中map方法返回的是列表
# Python3.x 中map方法返回的是迭代器
iter_map = map(lambda x: x ** 2, range(5))
for item in item_map:
    print(item, end=" ")
# >> Output: 0 1 4 9 16 

Reduce(规约):

Reduce过程就是把map输出的结果汇总到一起。继上面的例子,我们计算了几个数的平方,现在需要求他们的和。reduce方法需要传入两个参数,然后递归地对每个参数(除第一个参数)执行运算

# Python2.x 中可以直接调用reduce
# Python3.x 中将reduce放到functools里面了
from functools import reduce
iter_map = map(lambda x: x ** 2, range(5))  # 0 1 4 9 16
reduce(lambda: x, y: x + y, iter_map)  # 0+1+4+9+16 = 30
# >> Output: 30

例子:

Hadoop中的一个例子,用MapReduce实现的词频统计:

学习笔记:MapReduce on Ray_第1张图片

假设现在要统计Wikipedia上所有文章的词频,单机是放不下了。一个简单的思路就是将文章分批(Splitting),每个机器分别处理一批数据(Mapping),最后再将数据汇总(Reducing)。在汇总之前,需要将相同的单词放在一起(Shuffling),以便汇总。

在本文中,我们只关心Mapping和Reducing步骤的实现。

Ray

什么是Ray?

“Ray is a flexible, high-performance distributed execution framework.”

Ray是由UC Berkeley的RISELab (Real­Time Intelligence with Secure Execution) 提出的一种新型的分布式执行框架。RISELab前身是AMPLab,AMPLab (Algorithms, Machines and People Lab) 主导研发了Spark等在大数据领域著名的项目。从RISE的名字可以知道,实验室目前侧重于安全的、实时的智能系统。

Ray即如此,其主要是为深度学习、增强学习和分布式训练等量身定做,并能做到实时计算(尤其是在增强学习领域,如自动驾驶,人们会更加关注性能表现)。按照目前的发展趋势来看,Ray的出现是很有必要。

有兴趣的读者可以看看《UC Berkeley提出新型分布式执行框架Ray》,和知乎上的问题《如何看UCBerkeley RISELab即将问世的Ray,replacement of Spark?》中的高赞回答。网上可搜到,文末也会给出。两篇资料都分析得挺到位,在此不再复述了。读者如果感兴趣的话也可以读读Ray的论文,当然以后如果时间允许的话我也会写一篇关于Ray的paper reading.

安装Ray很简单:

pip install ray

如果你使用的是Anaconda,注意目前无法通过conda来安装。当然,你也可以通过源码安装/Docker等方式来安装。

在每次使用Ray之前,需要执行 ray.init() 来初始化Ray:

import ray
ray.init()
""" Output: 
Process STDOUT and STDERR is being redirected to /tmp/ray/session_2018-12-22_17-15-29_3097/logs.
Waiting for redis server at 127.0.0.1:52922 to respond...
Waiting for redis server at 127.0.0.1:50403 to respond...
Starting the Plasma object store with 6.714851328 GB memory using /dev/shm.

======================================================================
View the web UI at http://localhost:8890/notebooks/ray_ui.ipynb?token=d187db1e2b1d612be2336a469836843fa6d7e9a15fba3564
======================================================================
"""

调用ray.init()之后,我们可以看到Ray启动了Redis server, 如果只是使用单机版本的Ray的话,可以暂不关心Redis server的地址等信息,只知道它初始化成功即可。

关于ray.init()的主要启动参数:

  • redis_address: (str) – 要连接的Redis server的地址,不填则默认在本地启动。(程序退出时会关掉Redis server)
  • num_cpus: (int) – 本地调度器配置的cpu数量
  • ignore_reinit_error: (bool) – 如果第二次调用ray.init,程序会报错并退出,设为True可不抛出异常
    具体解析和API文档请移步至: The Ray API - Ray 0.6.0 documentation

Ray的简单使用:

在Ray中,分别通过ray.put()ray.get()方法来设置和读取变量的值。其中,ray.put() 方法返回的是对象的id, ray.get() 方法需要传入对象的id,返回变量的值。如:

x = "Hello Ray"
x_id = ray.put(x)
# 当然,ray.put()的传入参数也可以是number, list...等其他类型,如:
#    x_id = ray.put([i for i in range(10)])
print(x_id)
# >> Output: ObjectID(ffffffffba55fc8d16f249d14868946b44ff9652)

x_res = ray.get(x_id)  # ray.get()中,传入的参数也可以是ObjectID的list
print(x_res)
# >> Output: Hello Ray

(搬运Ray文档中的例子)

在单线程的情况下,如果函数f()的执行时间为~5s, 显然下面的例子需要执行~20s.

def f():
    time.sleep(5)
    return 0

tic = time.time()
results = [f() for _ in range(4)]
toc = time.time()
print(toc - tic)
# >> Output: 20.014071226119995

如果使用Ray分布式执行框架,只需要在函数定义时加上ray.remote作为函数的修饰即可。但需要注意的是,Ray中的远程函数不能直接调用,需要通过f.remote()来调用,调用结果返回对象的id, 再通过ray.get()就可以获取执行的结果。

如下:

@ray.remote
def f():
    time.sleep(5)
    return 0

tic = time.time()
ray.init()
results = ray.get([f.remote() for _ in range(4)])
toc = time.time()
print(toc - tic)
# >> Output: 5.0089240074157715

# for i in range(4):
#     print(f.remote())
""" Output:
ObjectID(0100000069672b193fb42e9ead39fb73e8a656ba)
ObjectID(010000002c31d522f2ce9969946e38d2c928160d)
ObjectID(010000005a0910e5c35bd4e5d5447b880f34172a)
ObjectID(01000000e9e04c5ea8b146c730f77fb706528d1d)
"""

显然,使用Ray并行执行的速度远远比单线程的方法要快。虽然这样对比是不公平的,但是对于笔者这样的手残党来说,实现并行化的门槛大大降低了。

MapReduce on Ray

热身

我们还是以上面求一个list的平方和作为例子,Map步骤需要计算list中每一个数的平方,而Reduce步骤需要将list中的数汇总(求和)。在实现的过程中,为了对比的效果更明显,笔者在每个执行的过程中加了一秒的延迟。

首先是单线程的MapReduce实现:

# Map 步骤
def square_local(x):
    time.sleep(1)
    return x ** 2

# map_serial_res = map(lambda x: x ** 2, range(5))
map_serial_res = map(square_local, range(5))
# >> Output: [0, 1, 4, 9, 16]


# Reduce 步骤
def sum_local(x, y):
    time.sleep(1)
    return x + y

# reduce_serial_res = reduce(lambda x, y: x + y, map_serial_res)
reduce_serial_res = reduce(sum_local, map_serial_res)
# >> Output: 30

需要注意的是,Ray中的远程函数(remote)不能直接调用,需要借助remote方法来调用,如func.remote(x1, x2, ...) . 所以在定义Ray的远程函数后,还需要定义一个远程执行的方法(下面code的reduce_parallel()方法)。

在Reduce步骤中,我们采用分治的方法来求和,而求每两个数的和交给Ray的remote function来解决。

不幸的是,这样做的话,IO是一笔不小的开销。除了调度之外,每次计算结果还需要调用 ray.get() 获取。因此在这里采用分治来求和是不明智的选择,在这个例子这样做只是为了体现并行化而已。在实际问题中,通常需要把数据划分成n个batch,而不是细化到两个元素,这样可以大大减少IO的开销。

# Map 步骤
@ray.remote
def square_remote(x):
    time.sleep(1)
    return x ** 2

# exec ray remote function
map_parallel = ray.get(map_parallel(square_remote, range(5)))
# >> Output: [0, 1, 4, 9, 16]


# Reduce 步骤
@ray.remote
def sum_remote(x, y):
    time.sleep(1)
    return x + y


# 这里采用分治算法来执行,如:
# input: [0, 1, 4, 9, 16]
# ->    [0, 1]  [4, 9, 16]
# ->      1,   [4], [9, 16]
# ->      1,     4,   25 
# ->      1,        29
# ->          30
def reduce_parallel(func, xs):
    len_xs = len(xs)
    if len_xs == 1:
        return xs[0]
    elif len_xs == 2:
        return ray.get(func.remote(xs[0], xs[1]))

    x_left = xs[:(len_xs // 2)]
    x_right = xs[(len_xs // 2):]

    return reduce_parallel(func, x_left) + reduce_parallel(func, x_right)

reduce_parallel_res = reduce_parallel(sum_remote, map_parallel_res)
# >> Output: 30

实战

关于MapReduce,很多教程都是以WordCount来作为例子,毕竟不少搜索引擎(如Google)的原理就是通过WordCount来建立单词到文档的索引,而网络上的数据庞大,自然而然就需要用到分布式的相关技术了。

本文搬运了Ray中用MapReduce实现的WordCount,

Streaming MapReduce on Ray

预处理

demo中的数据是用了wikipidia, 使用pip安装wikipidia库后直接调用就可以获取文章内容了,用来作为测试的语料库是个不错的选择。获取文章内容之后,我们首先要对文本进行一些简单处理:(分词)

  • 英文的分词比较简单,根据空格将单词分开,再去掉符号即可(当然也可以先去掉符号再分词,注意符号需要用空格或者其他符号代替);如果是中文的分词,可以选用 “jieba” 等开源的中文分词库。

  • 统计词频的话这里使用collections库里面的Counter方法

如:(在这里我们只使用空格和换行符来作为分隔符,当然也可以加上其他的符号)

import re
from collections import Counter

text = """
Since 1989, Guangdong has topped the total GDP rankings among all provincial-level divisions,
with Jiangsu and Shandong second and third in rank.
According to state statistics,
Guangdong's GDP in 2017 reached 1.42 trillion US dollars (CNY 8.99 trillion),
making its economy roughly the same size as Mexico.
Since 1989, Guangdong has had the highest GDP among all provinces of Mainland China.
The province contributes approximately 12% of the PRC's national economic output,
and is home to the production facilities and offices of a wide-ranging set of Chinese and foreign corporations.
Guangdong also hosts the largest import and export fair in China,
the Canton Fair,
hosted in the provincial capital of Guangzhou.
"""

print(Counter(re.split(r" |\n", text)))

# >> Output: 
"""
Counter({'the': 8, 'and': 6, 'of': 5, 'in': 4, 'Guangdong': 3,
 'GDP': 3, '': 2, 'Since': 2, '1989,': 2, 'has': 2, 'among': 2,
...
})
"""

首先我们创建一个Stream类,类似于"迭代器",类中的next方法随机地从关键词列表中获取一个关键词。所以这里会重复地统计几篇文章的词频,结果会不准确。如果关键词列表足够多的话,可以不用random方法,直接用Python的迭代器即可。

class Stream(object):
    def __init__(self, elements):
        self.elements = elements

    def next(self):
        i = np.random.randint(0, len(self.elements))
        return self.elements[i]

Map

其实上面(预处理部分)做的分词和统计词频的工作,就是Map步骤主要的内容。Mapper步骤主要的工作内容有:

  • 获取文章内容

  • 分词&统计词频(get_new_article),返回结果给Reducer处理(get_range)

实现如下:

@ray.remote
class Mapper(object):
    def __init__(self, title_stream):
        self.title_stream = title_stream
        self.num_articles_processed = 0
        self.articles = []
        self.word_counts = []

    def get_new_article(self):
        # 获取文章内容
        article = wikipedia.page(self.title_stream.next()).content
        # 分词&统计词频
        self.word_counts.append(Counter(re.split(r" |\n", article)))
        self.num_articles_processed += 1

    def get_range(self, article_index, keys):
        # Process more articles if this Mapper hasn't processed enough yet.
        while self.num_articles_processed < article_index + 1:
            self.get_new_article()
        # Return the word counts from within a given character range.
        return [(k, v) for k, v in self.word_counts[article_index].items()
                if len(k) >= 1 and k[0] >= keys[0] and k[0] <= keys[1]]

Reduce

Reducer就是负责获取Mapper的结果(next_reduce_result). 即:
Mapper.get_new_article --> Mapper.get_range --> Reducer.next_reduce_result

@ray.remote
class Reducer(object):
    def __init__(self, keys, *mappers):
        self.mappers = mappers
        self.keys = keys

    def next_reduce_result(self, article_index):
        word_count_sum = defaultdict(lambda: 0)

        # 调用mapper的get_range方法获取结果
        # (注意ray的远程函数需要通过remote来调用和传参)
        count_ids = [mapper.get_range.remote(article_index, self.keys)
                     for mapper in self.mappers]

        # TODO(rkn): We should process these out of order using ray.wait.
        for count_id in count_ids:
            for k, v in ray.get(count_id):
                word_count_sum[k] += v
        return word_count_sum

最后,初始化Ray, 创建关键词列表,调用……

ray.init(
        include_webui=False,
        ignore_reinit_error=True
        )

# Create 3 streams
kw_list = ["SenseTime", "AI", "MapReduce"]
streams = [Stream(kw_list) for _ in range(3)]

# Partition the keys among the reducers.
chunks = np.array_split([chr(i) for i in range(ord("a"), ord("z") + 1)], 4)
keys = [[chunk[0], chunk[-1]] for chunk in chunks]

# Create a number of mappers.
mappers = [Mapper.remote(stream) for stream in streams]

# Create a number of reduces, each responsible for a different range of
# keys. This gives each Reducer actor a handle to each Mapper actor.
reducers = [Reducer.remote(key, *mappers) for key in keys]

# Map & Reduce
for article_index in range(10):
    print("article index = {}".format(article_index))
    wordcounts = {}
    counts = ray.get([reducer.next_reduce_result.remote(article_index)
                      for reducer in reducers])
    for count in counts:
        wordcounts.update(count)
        
    # get most 10 frequent words
    most_frequent_words = heapq.nlargest(10, wordcounts,
                                         key=wordcounts.get)
    for word in most_frequent_words:
        print("  ", word, wordcounts[word])

Reference

  • MapReduce论文: Dean J, Ghemawat S. MapReduce: simplified data processing on large clusters[J]. Communications of the ACM, 2008, 51(1): 107-113.
  • Wikipedia MapReduce: MapReduce - Wikipedia
  • Ray论文: Moritz P, Nishihara R, Wang S, et al. Ray: A Distributed Framework for Emerging {AI} Applications[C]//13th {USENIX} Symposium on Operating Systems Design and Implementation ({OSDI} 18). 2018: 561-577.
  • Ray 项目源码: ray-project/ray
  • Ray 项目文档: Ray - Ray 0.6.0 documentation
  • Ray MapReduce: https://github.com/ray-project/ray/blob/master/examples/streaming/streaming.py
  • AI前线:UC Berkeley提出新型分布式执行框架Ray:有望取代Spark
  • 如何看UCBerkeley RISELab即将问世的Ray,replacement of Spark?

最后唠叨两句

可能的话,之后会写关于Ray的paper reading, 以及Ray在Reinforcement Learning的一些实践。
文笔不好,见笑了。

About author: 吴嘉熙,双非野鸡本科在读,商汤科技见习研究员。
转载请说明出处,并告知原作者(让我开心一下),谢谢!

你可能感兴趣的:(分布式系统,分布式计算,MapReduce,分布式系统)