多线程优化spaCy解析与实体识别技术

本文详细介绍了如何通过Cython和OpenMP实现spaCy语法依赖解析和命名实体识别模型的无GIL多线程处理,包括.pipe()方法的实现原理、GIL机制突破技术以及性能对比数据,显著提升大规模文本处理效率。

多线程spaCy解析器与命名实体识别器

在v0.100.3版本中,静默推出了对spaCy语法依赖解析和命名实体识别模型的全局解释器锁(GIL)无关多线程支持。由于这些模型占用大量内存,长期希望释放其GIL限制。实现后效果出乎意料,因此延迟了庆祝并转向其他工作,现在终于进行总结。

本文主要关注实现细节,但实现是痛苦而产品是愉悦的。让我们从成果开始:成果是.pipe()方法,它为spaCy添加了数据流功能:

流式解析

1
2
3
4
import spacy
nlp = spacy.load('de')
for doc in nlp.pipe(texts, n_threads=16, batch_size=10000):
    analyse_text(doc)

迭代器

.pipe()方法接受迭代器(如上文的texts)并生成迭代器。内部累积缓冲区(由batch_size参数指定),允许多个线程同时处理批次。批次完成后,从迭代器生成处理后的文档。

每个文档独立处理,因此若批次足够大且启用OpenMP,应能在内存中仅保留一份spaCy模型副本的情况下使用所有核心。spaCy设计用于网络级数据处理——旨在支持对Common Crawl全量转储数据进行复杂语言分析。通过有效的共享内存并行,这些任务成本大幅降低。

解析20,000文档所需时间(秒),线程数1/2/4(数值越低越好):

方法 线程数 秒数
循环 1 1691s
Pipe 1 1678s
Pipe 2 432s
Pipe 4 312s

Python、Cython与全局解释器锁

CPython的全局解释器锁(GIL)已被广泛讨论。对大多数代码不是问题,但对spaCy确实如此。计算机速度快且越来越快,但互联网庞大且越来越庞大。需要处理大量文本,并高效利用机器。

Python在全局数据结构中维护引用计数。创建或删除Python对象时,其引用计数必须更改。但持有引用计数的数据结构非线程安全。因此更改引用计数需要获取GIL。

绕过GIL的一种方法是避免使用Python变量。这正是spaCy的做法。具体来说,spaCy是Python库,但实际并非用Python编写。它用Cython实现,并转译为C++扩展模块。

在普通Python代码中,可以这样创建数字列表:

1
my_list = [0, 1, 2]

在Cython中可编写相同代码,但代码不由Python直接解释,而是转译为调用Python C-API的C/C++代码。部分生成代码如下:

1
2
__pyx_t_1 = PyList_New(3); 
// ...(详细C代码)

不持有GIL时无法调用这些函数,但可调用普通C/C++函数如malloc()和free():

1
2
3
4
5
6
7
from libc.stlib cimport malloc, free
my_arr = <int*>malloc(sizeof(int) * 3)
my_arr[0] = 1
my_arr[1] = 2
my_arr[2] = 3
do_stuff(my_arr)
free(my_arr)

Cython的nogil关键字允许声明函数在不持有GIL时可安全调用。使用nogil语义的缺点明显——仅限于用( arguably)更好语法编写C。若未尝试过,脱离Python语义是值得练习的,这让人更欣赏语言提供的功能。最怀念的可能是异常和列表,Python unicode对象也非常有用。

解析器.pipe方法实现

以下是spaCy中Parser.pipe方法的实现。该方法执行以下操作:

  • 将文本缓冲到临时工作数组
  • 释放GIL
  • 在OpenMP prange循环中迭代工作数组
  • 为每个工作单元(每个文档)调用Parser.parseC()方法
 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
33
34
35
36
37
38
39
def pipe(self, stream, int batch_size=1000, int n_threads=2):
    cdef Pool mem = Pool()
    cdef TokenC** doc_ptr = <TokenC**>mem.alloc(batch_size, sizeof(TokenC*))
    cdef int* lengths = <int*>mem.alloc(batch_size, sizeof(int))
    cdef Doc doc
    cdef int i
    cdef int nr_class = self.moves.n_moves
    cdef int nr_feat = self.model.nr_feat
    cdef int status
    queue = []
    for doc in stream:
        doc_ptr[len(queue)] = doc.c
        lengths[len(queue)] = doc.length
        queue.append(doc)
        if len(queue) == batch_size:
            with nogil:
                for i in cython.parallel.prange(batch_size, num_threads=n_threads):
                    status = self.parseC(doc_ptr[i], lengths[i], nr_feat, nr_class)
                    if status != 0:
                        with gil:
                            sent_str = queue[i].text
                            raise ValueError("Error parsing doc: %s" % sent_str)
            PyErr_CheckSignals()
            for doc in queue:
                self.moves.finalize_doc(doc)
                yield doc
            queue = []
    batch_size = len(queue)
    with nogil:
        for i in cython.parallel.prange(batch_size, num_threads=n_threads):
            status = self.parseC(doc_ptr[i], lengths[i], nr_feat, nr_class)
            if status != 0:
                with gil:
                    sent_str = queue[i].text
                    raise ValueError("Error parsing doc: %s" % sent_str)
    PyErr_CheckSignals()
    for doc in queue:
        self.moves.finalize_doc(doc)
        yield doc

多线程的实际机制非常简单,因为NLP(通常)是令人尴尬的并行——每个文档独立解析,因此只需在文本流上创建prange循环。prange函数是自动魔法的工作共享循环,为您管理OpenMP语义。仍需要考虑错误共享、线程安全等所有使编写多线程代码具有根本挑战性的部分。但至少调用语法清晰,且一些附带细节已处理。

困难部分

无法轻松地说多线程解析器是容易的。至少不能坦然地说。从未编写过重要的Java程序,但想象中编写多线程Java要容易得多。使用Cython,任务至少是可能的,但绝对不容易。

若算上学术时间,已用Cython编写统计解析器五六年,一直希望释放解析循环的GIL。到2015年底,机器学习、哈希表、外部解析循环和大部分特征提取已作为nogil函数。但状态对象接口复杂,且作为cdef类实现。无法创建此对象或将其存储在容器中而不获取GIL。

突破点在于发现了一种在Cython中编写C++类的未文档化方法。这允许挖空控制解析器状态的现有cdef类,逐方法将其接口代理到内部C++类。这样可以保持代码工作,并确保不会在特征计算中引入任何细微错误。

结论

自然语言处理(NLP)程序具有一些特殊的性能特征。涉及的算法和数据结构通常相当复杂,而执行的数值计算通常非常琐碎。

spaCy的解析器和命名实体识别系统必须使用线性模型对输入文档的每个单词进行两三次预测。所有复杂性都在特征提取和状态管理代码中,计算瓶颈最终是从主内存检索权重数据。

当最终切换到神经网络模型时,考虑因素会有所不同。可以实现解析器以同时处理多个句子。然而,这有其自身挑战。认为并行化外部循环既更容易又更有效,因此期望当前实现的工作将很好地服务我们。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计