在实时计算中,端到端的响应延迟是衡量计算性能时最重要的指标。DolphinDB 内置的流数据框架支持流数据的发布与订阅、流式增量计算、实时关联等,用户能够快速实现复杂的实时计算任务,达到毫秒级甚至亚毫秒级的效果,而无需编写大量代码。
本文介绍如何对 DolphinDB 流计算任务进行全链路的时延统计,以及如何优化脚本以实现更低时延的实时计算:
在关键链路上记录处理的时刻,可以反映流计算各个环节的时延。此外,DolphinDB 流计算引擎本身还内置了耗时统计功能,可提供更详细的耗时情况,帮助进行分析与进一步性能优化。
在数据发布、接收以及计算结果输出时,通过 now 函数获取当前时刻,并在数据表中记录数据到达各个环节的时刻。示例脚本如下:
// create engine and subscribe
share streamTable(1:0, `sym`time`qty`SendTime, [STRING, TIME, DOUBLE, NANOTIMESTAMP]) as tickStream
result = table(1000:0, `sym`time`factor1`SendTime`ReceiveTime`HandleTime, [STRING, TIME, DOUBLE, NANOTIMESTAMP, NANOTIMESTAMP, NANOTIMESTAMP])
dummyTable = table(1:0, tickStream.schema().colDefs.name join `ReceiveTime, tickStream.schema().colDefs.typeString join `NANOTIMESTAMP)
rse = createReactiveStateEngine(name="reactiveDemo", metrics =[
结果表 result 如下,SendTime 是数据注入发布端流数据表的时刻,ReceiveTime 是订阅端接收到输入数据的时刻,HandleTime 是响应式状态引擎进行因子计算结束的时刻。
在流计算引擎的 metrics 参数中使用 now 函数需要注意以下几点:
将一批数据批量注入引擎,通过 timer 语句统计从数据注入引擎到本批数据全部计算结束的总耗时。本方法适用于在因子开发和优化阶段快速验证引擎的计算性能。具体实践请参考 金融因子流式实现教程:4.3 性能测试 ,文中用 timer 语句比较了同一个因子的 4 种不同的实现对应的性能。
timer getStreamEngine("reactiveDemo").append!(data)
完整的示例脚本如下:
// create engine
def sumDiff(x, y) {
return (x-y)/(x+y)
}
factor1 =
share streamTable(1:0, `sym`time`price, [STRING, TIME, DOUBLE]) as tickStream
result = table(1000:0, `sym`time`factor1, [STRING, TIME, DOUBLE])
rse = createReactiveStateEngine(name="reactiveDemo", metrics =[
在 GUI 中耗时统计如下:
1.3 使用 outputElapsedMicroseconds 参数统计流计算引擎的耗时明细
在 2.00.9、1.30.21 及以上版本中,流计算引擎新增了 outputElapsedMicroseconds 参数,该参数设置为 true 时表示开启对引擎内部计算耗时明细的统计,统计结果将作为两列输出到结果表中。目前支持该参数的引擎包括createTimeSeriesEngine、createDailyTimeSeriesEngine、createReactiveStateEngine、createWindowJoinEngine。示例脚本如下:
// create engine
share streamTable(1:0, `sym`time`qty, [STRING, TIME, LONG]) as tickStream
result = table(1000:0, `sym`cost`batchSize`time`factor1`outputTime, [STRING, LONG, INT, TIME, LONG, NANOTIMESTAMP])
rse = createReactiveStateEngine(name="reactiveDemo", metrics =[
结果表 result 如下,cost 列和 batchSize 列为耗时明细,cost 列为引擎内部处理时每一个批次的计算耗时(单位:微秒),batchSize 列为同一批次处理的总记录数。
本例向响应式状态引擎注入了一批共 10 条数据,在引擎内部实际上分为了 5 个小的批次处理, 这是响应式状态引擎的特性和输入数据之间的顺序决定的。在同一个分组内响应式状态引擎逐条处理输入数据,同时,不同分组会放到同一批做向量化处理,因此可以看到,第一条分组列为 000001.SZ 的记录单独是一个批次,之后第二条 000001.SZ 和随后的 000002.SZ 在同一批中进行了计算输出。
本次 10 条输入数据注入引擎的总计算耗时:
select sum(cost\batchSize) as totalCost from result
在 DolphinDB 流计算框架中,实时数据首先注入到流数据表中,之后基于发布-订阅-消费模型,由发布端主动推送增量的输入数据至消费端,通过回调的方式在消息处理线程上不断执行指定好的消息处理函数进行计算,并将计算结果再写入到表中。纵观整个计算链路,我们发现可以从写入、计算和框架等三个方面进行流计算任务的性能优化。
DolphinDB 流计算框架的核心之一是流数据表,在整个计算链路中往往涉及到多次对流数据表的写入:比如从外部设备或者交易系统等源源不断产生的记录会实时写入流数据表,以作为之后实时计算的输入;此外,流计算引擎的计算结果也需要输出到表中。总之,对表的写入耗时会体现在整体的计算时延上,通过以下方式可以优化写入耗时。
创建时指定足够大的 capacity 参数。比如对于股票行情数据可以根据历史数据预估好一天的数据总量,将 capacity 设置为略大于该数值。
capacity = 1000000
share(streamTable(capacity:0, `sym`time`price, [STRING,DATETIME,DOUBLE]), `tickStream)
streamTable 函数的 capacity 参数是正整数,表示建表时系统为该表分配的内存(以记录数为单位)。当记录数首次超过 capacity 时,系统会首先分配 capacity 的1.2倍的新的内存空间,然后复制数据到新的内存空间,最后释放原来的内存,再写入新的数据。当记录数继续增加并超过被分配的内存空间时,会再次进行类似的扩容操作。
从时延统计上看,由于扩容涉及到内存分配和复制等较为耗时的操作,因此若某一批数据写入时触发了扩容则会出现一次写入时延的峰值。可以想象,如果 capacity 仅仅设置为 1 ,在持续写入的过程中则会发生多次动态扩容,每次的峰值耗时将随总数据量增加而逐渐增高,因为需要拷贝的数据越来越多。
创建时指定合理的 cacheSize 和 capacity 参数。在内存充足的情况下,对于股票行情数据可以根据历史数据预估好一天的数据总量,将 cacheSize 和 capacity 设置为略大于该数值。
cacheSize = 1000000
enableTableShareAndPersistence(table=streamTable(cacheSize:0, `sym`time`price, [STRING,DATETIME,DOUBLE]), tableName="tickStream", asynWrite=true, compress=true, cacheSize=cacheSize, retentionMinutes=1440, flushMode=0, preCache=10000)
enableTableShareAndPersistence 函数的 cacheSize 参数是长整型数据类型(long)的整数,表示该表在内存中最多保留多少行,当内存中的行数超过 cacheSize 且确认目前的所有数据都已经保存到磁盘后,系统会申请新的内存空间,将内存中后 50% 的数据拷贝到新的内存空间,并释放掉原来的内存。
从时延统计上看,由于清理内存中的过期数据涉及到内存分配、复制等较为耗时的操作,因此若某一批数据写入时触发了清理则会出现一次写入的时延峰值。若内存充足,则可以考虑设置足够大的 cacheSize 将全部数据都保留在内存里,不触发清理操作。若需要进行内存控制,则设置合理 cacheSize 以平衡峰值出现的频率和单次峰值的大小,因为 cacheSize 的大小决定了拷贝的数据量进而会影响单次的耗时。
流计算引擎是 DolphinDB 中专门用于处理实时流计算的模块,不同的流计算引擎对应不同的计算场景,如窗口聚合、事件驱动等。创建流计算引擎需要指定 metrics 参数,其为以元代码的形式表示计算公式,引擎的 metrics 为实时指标在 DolphinDB 脚本中的实现。针对不同场景选择合适的流计算引擎后,实现高效的实时指标则是降低流计算时延的关键,因此,本章前三小节介绍在流式指标实现上的优化方法,最后一小节介绍在部分计算场景中不必使用流计算引擎的建议。
假设我们接收实时逐笔成交数据,并对每条成交记录逐条响应,累计最新的当日成交总量,如果每一次计算都是用截止当前的全量成交数据,则会响应性能不佳,而通过增量的流式实现则可以大大提升性能。具体的,最新的累计成交总量可以在上一次计算出的成交总量的基础上加上最新的一条成交记录的成交量得到,可以看到,这里的增量计算需要用到历史状态(上一次计算出的成交总量),我们称之为有状态计算。
DolphinDB 在响应式状态引擎、时序聚合引擎、窗口连接引擎中,提供了大量的内置状态函数,在 metrics 中使用内置状态函数即可以实现上述的增量有状态计算,而历史状态由引擎内部自动维护。比如,在响应式状态引擎中使用 cumsum 函数可以实现增量的累加。
各个引擎已经支持的状态函数请参考用户手册 createTimeSeriesEngine、 createReactiveStateEngine、createWindowJoinEngine,建议优先选择内置状态函数以增量算法实现实时指标。
即时编译 (英文: Just-in-time compilation, 缩写: JIT),又译及时编译或实时编译,是动态编译的一种形式,可提高程序运行效率。DolphinDB 的编程语言是解释执行,运行程序时首先对程序进行语法分析生成语法树,然后递归执行。在不能使用向量化的情况下,解释成本会比较高。这是由于 DolphinDB 底层由 C++ 实现,脚本中的一次函数调用会转化为多次 C++ 内的虚拟函数调用。JIT 具体介绍请参考 DolphinDB JIT 教程 。
流计算任务由于不断地被触发计算,因此会反复地调用函数,以 1.3 小节响应式状态引擎的计算为例,仅仅注入 10 条数据便会触发 5 次对 metrics 中函数的调用。解释耗时也会反应到整体的计算耗时中,尤其对于响应式状态引擎,在某些场景下总计算耗时中可能大部分为解释耗时,因此,建议通过实现 JIT 版本的函数来减少解释成本 。具体实践请参考 金融因子流式实现教程:4.2 即时编译 (JIT) ,文中使用 JIT 优化了移动平均买卖压力指标的性能。
DolphinDB 中的数组向量 (array vector) 是一种特殊的向量,用于存储可变长度的二维数组。这种存储方式可以显著简化某些常用的查询与计算。array vector 具体介绍请参考 DolphinDB array vector 教程。
十档量价是行情快照中最重要的信息,实现高频因子时为了让十档量价数据能够方便地实现向量化计算,在 DolphinDB 中可以通过 fixedLengthArrayVector 函数组合十档数据。针对这个特点,建议直接使用数组向量 (array vector) 来存储 level 2 行情快照中的十档量价字段,以省去函数实现过程中组装十档数据的步骤,来降低计算延时。具体实践请参考请参考 金融因子流式实现教程:4.1 数组向量 (array vector) ,文中使用 array vector 优化了移动买卖压力指标的性能。
并不是所有的计算都需要通过流计算引擎进行,一些简单的无状态计算建议在自定义函数中实现。比如,下例希望在引擎中仅对 9:30 之后的数据进行计算,则可以在 handler 函数中首先对输入数据进行过滤再注入计算引擎。
def filterTime(msg){
tmp = select * from msg where time > 09:30:00.000
getStreamEngine("reactiveDemo").append!(tmp)
}
subscribeTable(tableName="tickStream", actionName="demo", offset=-1, handler=filterTime, msgAsTable=true)
在某些场景下,对单个指标的实现已经优化到了极致,但是由于输入数据流量极大,仍然可能导致系统来不及处理,也会表现为相当大的响应时延。若单位时间内输入数据的流量总是大于系统能够处理的流量,则会看到在订阅端的消息处理线程监控表(getStreamingStat().subWorkers)中的 queueDepth 数值不断上升,这是流计算任务不健康的表现,需要停止任务并优化。
DolohinDB 流计算任务是通过流数据表、流计算引擎、消息处理线程等不同模块的组合来构建的。流计算任务可以被简单理解为后台线程在反复执行一个计算函数,流计算引擎以及其他自定义函数可以被指定为计算函数,流数据表以发布订阅的方式不断地触发后台线程调用对应的计算函数,每次函数调用的入参是流数据表中新增的一批记录。本小节从搭建流计算任务的框架方面介绍优化方法。
使用 subscribeTable 时设置适当的 batchSize 和 throttle 参数,可以达到微批处理的效果,以此来提升吞吐避免阻塞,进而降低时延。这是基于单次处理的耗时并不是随着单次处理的数据量而线性增长的,最典型的场景是实时写入分布式数据库,一次写入 1000 条数据和写入 10 条数据的耗时可能相差无几,那么发挥数据批处理的性能优势则可以降低整体的时延。
batchSize 和 throttle 参数均表示了触发消费的条件,这两个条件之间是或的关系。订阅队列中的数据积累到 batchSize 设置的量时会触发消费,但是考虑到实际场景中,流量在不同时刻的波动,某些时候可能会长时间达不到 batchSize 而不能被消费,因此需要设置 throttle 参数,通过等待达到一定的时长来触发消费,消费会对队列中未处理的全部数据进行处理。 batchSize 和 throttle 参数建议综合考虑输入数据的流量和数据处理的速度进行调整。
此外,请注意以下两点:
使用 subscribeTable 时设置适当的 hash 参数,以提升并行度来降低时延。执行 subscribeTable 函数相当于为其 handler 参数对应的计算函数分配了一个固定的消息处理线程,合理的分配消息处理线程可以尽可能充分里利用 CPU 资源。大致分为以下两类优化建议:
DolphinDB 中实现复杂计算指标的思路是把指标分解成多个阶段,每一个阶段由一个计算引擎来完成。如果两个引擎之间由一张中间表来承接中间的计算结果,并且由中间表的发布订阅来串联两个引擎,则会存在一定的内存和耗时的开销。因此,DolphinDB 内置的流计算引擎均实现了数据表(table)的接口,允许将后一个引擎作为前一个引擎的输出,称为流计算引擎级联,与通过多个流数据表与多次订阅串联引擎相比,有更好的性能表现。实现脚本请参考 DolphinDB 流计算教程:4.1 流水线处理
在 2.3.2 小节中介绍了将某一个流计算任务拆分到多个线程上并行处理的优化建议,但是通过 subscribeTable 进行发布端过滤并提交多个并行任务的方式,可能会因为订阅的客户端太多而导致发布瓶颈,使得并发处理带来的优化受发布瓶颈的影响而减弱,因为在同一个节点上只有一个发布线程。在 1.30.22、2.00.9 以及之后的版本中,新增了流数据分发引擎(createStreamDispatchEngine),支持将输入的数据分发到不同的线程,并且在该线程中完成将增量的输入数据注入到对应的输出表的操作,这里的输出表可以指定为流计算引擎。
以下脚本中进行了一次发布订阅,在订阅端的消息处理线程中将数据写入到一个分发引擎中,由该分发引擎将数据按 sym 字段哈希分组后分发到 3 个不同的线程进行实际的计算,计算逻辑由响应式状态引擎定义。
// create engine
share streamTable(1:0, `sym`price, [STRING,DOUBLE]) as tickStream
share streamTable(1000:0, `sym`factor1, [STRING,DOUBLE]) as resultStream
rseArr = array(ANY, 0)
for(i in 0..2){
rse = createReactiveStateEngine(name="reactiveDemo"+string(i), metrics =, dummyTable=tickStream, outputTable=resultStream, keyColumn="sym")
rseArr.append!(rse)
}
dispatchEngine=createStreamDispatchEngine(name="dispatchDemo", dummyTable=tickStream, keyColumn=`sym, outputTable=rseArr, mode="buffer", dispatchType="uniform")
// subscribe
subscribeTable(tableName="tickStream", actionName="dispatch", handler=dispatchEngine, msgAsTable=true)
流数据分发引擎的原理:
流数据分发引擎的使用注意事项:
本文详细介绍了 DolphinDB 流计算中进行时延统计与性能优化的方法,以期帮助用户更好地分析和优化自己的流计算任务。流计算性能优化的大致思路如下:
发布于 2023-11-06 09:59・IP 属地浙江