背景
目前算法框架面临选型。需要知道在算法控制台需要支持窗口,水位线自定义调用算法插件等等需求. 由于目前面向从老到新框架的过渡阶段,
原先老的pyflink框架
目前以一个作业多点位进行测试。
在目前的算法框架当中,算法侧会在每次计算的中间阶段,算出汇总结果,中间计算结果,健康度三者的计算结果. 并且根据算法种类分为
-
实时算法
-
离线算法(识别算法,阻塞算法和事件算法)
-
其他算法(比如神经网络插件,用于在训练得到结果之后打标)
约束条件
-
当上游数据产生延迟时,水位线需要手动可配置
-
在数据攒批计算的时候,需要依赖滑动窗口
-
任务做定时merge的时候, 需要从上游三个分支支流拉取数据,合并输出到下游
旧的scdap框架简介
技术栈: celery/komou + python多进程
该框架采用master+多worker的架构实现, 其中master作为协调节点接收数据的主链路,然后按照对应的分发规则,分发给对应的worker实现。在此基础之上实现了数据分流以及对应的点位窗口攒批的语义。
该框架的核心分为三层
scdap/transfer 传输层, scdap/frame框架层, scdap/wp 上下文层
传输层: 职责在于从Master进程到worker进程的传输中间件
框架层: 职责在于为对应的点位攒批和keyby语义进行一些通用抽象
上下文层: 定义一些汇总算法,比如定时评估的上下文.存储对应的汇总信息以及对应的配置项
原先的链路流程
用户先在算法控制台配置work_flow json, 然后在算法控制台拖拉拽算子形成算子
根据评估-识别-事件算法传递到下游. 在此过程当中存在三份数据
- 明细数据(原始数据)
- 上一个算子的算法结果
- 额外的冗余数据(定时评估算法产生的评估结果)
这三份数据需要合并流程, 并将计算完成的算法结果通过侧输出流的方式进行订阅输出给事件算法
名词解释
komou: 本身是一个本地队列,但是可以用broker做代理,比如kafka,redis,rabbitmq都可 都是原先本地的put,remove的操作交给代理broker做托管。也就是说一次投递,多端消费 基于同一个exchange下的不同queue进行上下行的通信
设计的目的以及折中
分为两种实现 自研算法框架和迁移到ray框架
自研算法框架
个进程一个core。单纯的python多进程模型. master将数据分发到每个worker, 并归并同一种类型的工作流实例到对应的worker上执行(参考Mars的本地集群管理器)
- master只负责数据的接入以及分发给对应的worker节点
- 以设备维度采集的数据进行worker计算进程的隔离
- worker之间需要支持结果的合并(todo 可能需要内部起异步协程进行执行)
- candidate: 汇总节点负责订阅对应的komou的exchange,进行通信
功能覆盖
-
keyby算子
- 纯python数据分流 - 单Master协调进程+多worker + 本地队列管理器实现这个keyby语义
-
windows算子
- 通过pandas实现基于时间序列的攒批
def get_data(algorithm_id: str, start: datetime, stop: datetime,
select_column: List[str] = None, use_thread: bool = False, from_cache: bool = None) \
-> FeatureList:
"""
通过http接口获取特征数据
:param from_cache: 是否从缓存内读取, 默认为scdap.config.DEVICE_DATA_CACHE
:param algorithm_id: 算法点位编号
:param start: 起始时间
:param stop: 结束时间
:param select_column: 需要获取的特征
:param use_thread: 是否使用多线程获取数据
"""
if select_column is None:
select_column = column.normal_column
if start >= stop:
raise Exception('参数start的数值必须大于参数stop的数值.')
if use_thread:
flist = FeatureList(algorithm_id)
for f in generator_get_data(algorithm_id, start, stop, select_column, from_cache=from_cache):
flist += f
else:
flist = _get_data(config.API_DEVICE_DATA_GET_URL, algorithm_id,
start, stop, select_column, from_cache=from_cache)
flist._algorithm_id = algorithm_id
return flist
顶层代码抽象
master.py
master的代码
from kombu import Queue, Connection, producers
import conf.distrbute_app
from conf.manager import QueueManager
from core import queue
from core.queue import task_queues
from core.worker import Worker
from manager.task import alg_task
from network.channel_base import ChannelBase
class Master(ChannelBase):
def __init__(self):
self.task_queue_dict = conf.distrbute_app.CELERY_QUEUES
#注册所有Routing Key相关的队列
for task_queue in self.task_queue_dict:
QueueManager.register(task_queue.name, callable=lambda:task_queue)
#绑定端口5000, 设置验证码abc
manager = QueueManager(address=('localhost', 5000), authkey=b'abc')
manager.start()
def send(self):
priority = 'mid'
routing_key = conf.distrbute_app.priority_to_routing_key[priority]
connection = Connection()
func = alg_task
payload = {'fun': func, 'args': (), 'kwargs': {}}
with producers[connection].acquire(block=True) as producer:
producer.publish(payload,
serializer='pickle',
# compression='bzip2',
exchange=queue.task_exchange,
declare=[queue.task_exchange],
routing_key=routing_key)
pass
def recv(self):
#接受所有worker端的消费结果,看是否出现断联
connection = Connection()
channel = connection.channel()
consumer = Worker(channel, task_queues)
pass
def close(self):
pass
#注册回调函数的执行结果
def process_media(self):
#todo 实现对应的media信息
pass
worker.py
from kombu import Connection
from kombu.log import setup_logging, get_logger
from kombu.mixins import ConsumerMixin
from kombu.utils import reprkwargs, reprcall
class Worker(ConsumerMixin, ChannelBase):
"""
name: 该worker进程的名字
queue:该worker从哪个queue当中取出数据
"""
def __init__(self, name:str, connection: Connection, queue: Queue):
self.name = name
self.queue = queue
self.t = Process(target=self.main_loop, name=name)
self.channel = Channel(local_address="localhost:1000", dest_address="localhost:2000",
compression=Lz4Compress(), localQueue=queue)
def send(self, message: Any):
pass
def recv(self):
pass
if __name__ == '__main__':
setup_logging(loglevel='INFO', loggers=[''])
with Connection('amqp://root@localhost:5672//') as conn:
try:
worker = Worker(conn)
worker.run()
except KeyboardInterrupt:
print("error")
channel_base.py
""
进程间通信的管道(负责元信息注册,以及worker与queue的绑定)
"""
class ChannelBase(Channel, ABC):
#当有新通道加入的时候, 维护一个自增序号
_channel_index_gen = itertools.count
def __init__(self, local_address: str = None, dest_address: str = None,
chanel_index=None, channel_id: str = None, compression=None):
super().__init__(local_address=local_address,
dest_address=dest_address,
compression=compression)
self._channel_index = chanel_index or next(self._channel_index_gen)
self._channel_id = channel_id or ChannelID
def send(self, message: Any):
pass
def recv(self):
pass
base.py
class Channel(ABC):
"""
Channel is used to do data exchange between server and client.
"""
__slots__ = "local_address", "dest_address", "compression"
name = None
def __init__(
self, local_address: str = None, dest_address: str = None, compression: CompressAlg=None, localQueue: Queue = None
):
self.local_address = local_address
self.dest_address = dest_address
self.compression = compression
self.queue = localQueue
@abstractmethod
async def send(self, message: Any):
"""
Send data to dest. There should be only one send for one recv, otherwise recv messages
may overlap.
Parameters
----------
message:
data that sent to dest.
"""
@abstractmethod
async def recv(self):
"""
Receive data that sent from dest.
"""
@abstractmethod
async def close(self):
"""
Close channel.
"""
@property
@abstractmethod
def closed(self) -> bool:
"""
This channel is closed or not.
Returns
-------
closed:
If the channel is closed.
"""
@property
@abstractmethod
def type(self) -> ChannelType:
"""
Channel is used for, can be dummy, ipc or remote.
Returns
-------
channel_type: ChannelType
type that can be dummy, ipc or remote.
"""
@property
def info(self) -> Dict:
return {
"name": self.name,
"compression": self.compression,
"type": self.type,
"local_address": self.local_address,
"dest_address": self.dest_address,
}
class Server(ABC):
__slots__ = "address", "channel_handler"
scheme = None
def __init__(
self, address: str, channel_handler: Callable[[Channel], Coroutine] = None, change_config:str = "{}"):
self.change_config = change_config
self.address = address
self.channel_handler = channel_handler
self.configure(change_config)
@classproperty
@abstractmethod
def configure(self, config:Dict):
self.config = load_config(config, "config_str")
@classproperty
@abstractmethod
def client_type(self) -> Type["Client"]:
"""
Return the corresponding client type.
Returns
-------
client_type: type
client type.
"""
@property
@abstractmethod
def channel_type(self) -> ChannelType:
"""
Channel type, can be dummy, ipc or remote.
Returns
-------
channel_type: ChannelType
type that can be dummy, ipc or remote.
"""
@staticmethod
@abstractmethod
def create(config: Dict) -> "Server":
"""
Create a server instance according to configuration.
Parameters
----------
config: dict
configuration about creating a channel.
Returns
-------
server: Server
a server that waiting for connections from clients.
"""
@abstractmethod
def start(self):
"""
Used for listening to port or similar stuff.
"""
@abstractmethod
def join(self, timeout=None):
"""
Wait forever until timeout.
"""
@abstractmethod
def on_connected(self, *args, **kwargs):
"""
Return a channel when new client connected.
Returns
-------
channel: Channel
channel for communication
"""
@abstractmethod
def stop(self):
"""
Stop the server.
"""
@property
@abstractmethod
def stopped(self) -> bool:
"""
If this server is stopped or not.
Returns
-------
if_stopped: bool
This server is stopped or not.
"""
@property
def info(self) -> Dict:
return {
"name": self.scheme,
"address": self.address,
"channel_type": self.channel_type,
}
async def __aenter__(self):
await self.start()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.stop()
class Client(ABC):
__slots__ = "local_address", "dest_address", "channel"
scheme = None
def __init__(self, local_address: str, dest_address: str, channel: Channel):
self.local_address = local_address
self.dest_address = dest_address
self.channel = channel
@property
def channel_type(self) -> ChannelType:
"""
Channel type, can be dummy, ipc or remote.
Returns
-------
channel_type: ChannelType
type that can be dummy, ipc or remote.
"""
return self.channel.type
@staticmethod
@abstractmethod
async def connect(
dest_address: str, local_address: str = None, **kwargs
) -> "Client":
"""
Create a client that is able to connect to some server.
Parameters
----------
dest_address: str
Destination server address that to connect to.
local_address: str
local address.
Returns
-------
client: Client
Client that holds a channel to communicate.
"""
@implements(Channel.send)
async def send(self, message):
return await self.channel.send(message)
@implements(Channel.recv)
async def recv(self):
return await self.channel.recv()
async def close(self):
"""
Close connection.
"""
await self.channel.close()
@property
def closed(self) -> bool:
"""
This client is closed or not.
Returns
-------
closed: bool
If the client is closed.
"""
return self.channel.closed
@property
def info(self) -> Dict:
return {
"local_address": self.local_address,
"dest_address": self.dest_address,
"channel_name": self.channel.name,
"channel_type": self.channel_type,
}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
任务提交流程
client.py
from kombu import Connection
import network
from common.api import ExecutionConfig
from common.base import Channel
from common.typing import ClusterType, ClientType
from common.utils import load_config, logger
from conf import config, distrbute_app
from conf.config import MASTER_CONFIG, RESOURCE_CONFIG, WORKER_CONFIG
from conf.distrbute_app import queue_manager_address
from core.master import Master
from core.worker import Worker
from lib.isolation import Isolation
from network.session_api import SessionAPI
from network.session import new_session
class LocalCluster:
logger = get_logger(__name__)
def __init__(
self: ClusterType,
address: str = "0.0.0.0",
n_worker: int = 1,
n_cpu: Union[int, str] = "auto",
mem_bytes: Union[int, str] = "auto",
subprocess_start_method: str = None,
backend: str = None,
config: Union[str, Dict] = None,
web: Union[bool, str] = "auto",
n_supervisor_process: int = 0,
):
# auto choose the subprocess_start_method.
if subprocess_start_method is None:
subprocess_start_method = (
"spawn" if sys.platform == "win32" else "forkserver"
)
self._address = address
self._n_worker = n_worker
self._n_cpu = 4 if n_cpu == "auto" else n_cpu
self._mem_bytes =16 if mem_bytes == "auto" else mem_bytes
self._subprocess_start_method = subprocess_start_method
self._config = load_config(config, default_config_file=MASTER_CONFIG)
execution_config = ExecutionConfig.from_config(self._config, backend=backend)
self._backend = execution_config.backend
self._web = web
self._n_supervisor_process = n_supervisor_process
execution_config.merge_from(
ExecutionConfig.from_params(
backend=self._backend,
n_worker=self._n_worker,
n_cpu=self._n_cpu,
mem_bytes=self._mem_bytes,
subtask_cancel_timeout=self._config.get("scheduling", {}).get(
"subtask_cancel_timeout", 1000
),
subtask_max_retries=self._config.get("scheduling", {}).get(
"subtask_max_retries", 4
),
)
)
self._bands_to_resource = execution_config.get_deploy_band_resources()
self._supervisor_pool = None
self._worker_pools = []
self._exiting_check_task = None
self.web_address = None
self._master = None
@property
def backend(self):
return self._backend
@property
def external_address(self):
return self._supervisor_pool.external_address
async def start(self):
# start service
master, worker_list = await self._start_service()
# init metrics to guarantee metrics use in driver
metric_configs = self._config.get("metrics", {})
metric_backend = metric_configs.get("backend")
init_metrics(metric_backend, config=metric_configs.get(metric_backend))
master.start()
self._master = master
for worker_no in worker_list:
worker_no.start()
self._worker_pools = worker_list
if self._web:
#接入webui
self.web_address = ""
logger.warning("Web service started at %s", self.web_address)
self._exiting_check_task = asyncio.create_task(self._check_exiting())
async def _start_service(self):
# 初始化master和worker
# 返回相关的进程实例id
channel = Channel()
master = Master(address=queue_manager_address, change_config=config.MASTER_CONFIG)
worker_num = 4
worker_instance_list = []
queue = distrbute_app.CELERY_QUEUES
for item in range(worker_num):
worker_name = "worker-"+item
connection = Connection(distrbute_app.compute_upstream_exchage)
worker_node = Worker(name=worker_name, connection=connection, queue=
queue[worker_name])
worker_instance_list.append(worker_node)
# await start_worker(
# worker_pool.external_address,
# self.supervisor_address,
# band_to_resource,
# config=self._config,
# )
return master, worker_instance_list
async def stop(self):
# delete all sessions
session_api = SessionAPI.create(self._master)
session_api.delete_session(session_id=session_api.__address__)
for worker_pool in self._worker_pools:
await worker_pool.stop()
await self._supervisor_pool.stop()
迁移到ray框架
背景
目前算法框架由于历史原因选型错误,使用了pyflink. 现在后续需要接入多种类型的算法种类,并且还需要有更好的性能提升,pyflink不能很好的满足需求. 经过调研之后,选型Ray和Dask两款纯python的计算框架作为替换的实现.
文档: www.aidoczh.com/ray/ray-ove…
调研过程
因为pyflink存在跨语言通信的成本, 所以pyflink官方选择了分别使用进程模式和线程模式去跑.
进程模式目前是用beam框架进行传输的,这会对内存产生较大压力。所以不适合用于低开销资源的场景. 而线程模式当中,通过pemja做跨语言调用,虽然没有beam带来的内存压力。但是性能和进程模式的计算耗时差不多. 所以弃用pyflink作为算法依赖计算的框架
ray提供了自动扩缩容的资源管理, 并且worker用c++实现,通过Python的libcpp创建进程,以绕过GIL全局解释锁的限制,并且提供精细化的资源控制
由于未来的算法框架计算需要支持科学计算和分布式训练. 所以需要找到一个款纯python的框架。满足这个需求,目前的选型是ray(侧重分布式训练)以及dask(侧重科学计算)
Ray介绍
详细介绍: www.cnblogs.com/softlin/p/1…
应用层对应了三种类型的进程:驱动进程、工作器进程、行动器进程组成;
驱动器 (Driver ): 执行用户程序的进程,所有操作都需要由主进程来驱动。
工作器 (Worker ): 执行由驱动器或其他工作器调用的任务(远程函数)的无状态的进程。工作器是在系统层分配任务时自动启动的。当声明一个远程函数时,该函数将被自动发送到所有的工作器中。在同一个工作器中,任务是串行地执行的,工作器并不维护其任务与任务之间的局部状态,即在工作器中,一个远程函数执行完后,其局部作用域的所有变量将不再能被其他任务所访问。
行动器 (Actor ): 行动器被调用时只执行其所暴露的方法。行动器由工作器或驱动器显式地进行实例化。与工作器相同的是,行动器也会串行地执行任务,不同的是行动器上执行的每个方法都依赖于其前面所执行的方法所变更的状态。
部署架构
默认一主两从(head主节点,woker从节点)
目前算法框架由于历史原因选型错误,使用了pyflink. 现在后续需要接入多种类型的算法种类,并且还需要有更好的性能提升,pyflink不能很好的满足需求. 经过调研之后,选型Ray和Dask两款纯python的计算框架作为替换的实现.
文档: www.aidoczh.com/ray/ray-ove…
调研过程
因为pyflink存在跨语言通信的成本, 所以pyflink官方选择了分别使用进程模式和线程模式去跑.
进程模式目前是用beam框架进行传输的,这会对内存产生较大压力。所以不适合用于低开销资源的场景. 而线程模式当中,通过pemja做跨语言调用,虽然没有beam带来的内存压力。但是性能和进程模式的计算耗时差不多. 所以弃用pyflink作为算法依赖计算的框架
由于未来的算法框架计算需要支持科学计算和分布式训练. 所以需要找到一个款纯python的框架。满足这个需求,目前的选型是ray(侧重分布式训练)以及dask(侧重科学计算)
Ray介绍
详细介绍: www.cnblogs.com/softlin/p/1…
应用层对应了三种类型的进程:驱动进程、工作器进程、行动器进程组成;
驱动器 (Driver ): 执行用户程序的进程,所有操作都需要由主进程来驱动。
工作器 (Worker ): 执行由驱动器或其他工作器调用的任务(远程函数)的无状态的进程。工作器是在系统层分配任务时自动启动的。当声明一个远程函数时,该函数将被自动发送到所有的工作器中。在同一个工作器中,任务是串行地执行的,工作器并不维护其任务与任务之间的局部状态,即在工作器中,一个远程函数执行完后,其局部作用域的所有变量将不再能被其他任务所访问。
行动器 (Actor ): 行动器被调用时只执行其所暴露的方法。行动器由工作器或驱动器显式地进行实例化。与工作器相同的是,行动器也会串行地执行任务,不同的是行动器上执行的每个方法都依赖于其前面所执行的方法所变更的状态。
部署架构
默认一主两从(head主节点,woker从节点)
Ray 的节点需要运行两个进程,一个是 RayLet 进程,一个是 Plasma Store 进程(对应图中的 Object Store)。
其中,RayLet 进程中维护着一个 Node Manager 和一个 Object Manager。Ray 提供了 Python 的 API,而 RayLet 是用 C++ 实现的。Node Manager 充当论文中 Local Scheduler 的角色,主要负责管理 Node 下的 Worker,调度在 Node 上的任务,管理任务间的依赖顺序等。Object Manager 主要提供了从其他的 Object Manager Pull/Push Object 的能力。
Plasma Store 进程,是一个共享内存的对象存储进程。原本 Plasma 是 Ray 下的,目前已经是 Apache Arrow 的一部分。正如前文所述,Ray 在执行带有remote注解的函数时并不会立刻运行,而是会将其作为任务分发,其返回也会被存入 Object Store 中。这里的 Object Store 就是 Plasma[4]。
而论文中的 Control State,在实现中被叫做 GCS,是基于 Redis 的存储。而 GCS 是运行在一类特殊的节点上的。这类特殊的节点被称作 Head Node。它不仅会运行 GCS,还会运行对其他节点的 Monitor 进程等。
Ray 提交任务的方式与 Spark 非常类似,需要利用 Driver 来提交任务,而任务会在 Worker 上进行执行。Ray 支持的任务分为两类,分别是任务(Task)和 Actor 方法(ActorMethod) 。其中任务就是之前的例子中的被打上了remote注解的函数,而 Actor 方法是被打上了remote注解的类(或叫做 Actor)的成员方法和构造方法。
两者的区别在于任务都是无状态的,而 Actor 会保有自己的状态,因此所有的 Actor 方法需要在 Actor 所在的节点才能执行。这也是 Ray 跟 Spark 最大的不同。Spark 提交的是静态的 DAG,而 Ray 提交的是函数
任务提交流程
- 任务task
任务执行为无状态的,任务无法修改作为本地变量传入的值, Ray远程函数为无副作用的
编写任务流程:
- 注册任务: 在注册为任务的函数上添加@ray.remote装饰器
- 提交任务: 在调用@ray.remote装饰器的函数时带上.remote()
- 在函数级别,可以指定需要分配的cpu和资源
下面是一个ray简单的计算任务
import ray
import socket
import time
@ray.remote(num_cpus=1, memory=75 * 1024 * 1024)
class Counter(object):
def __init__(self):
# actor进程
self.value = 0
def increment(self):
# worker进程
self.value += 1
return self.value
if __name__ == '__main__':
import ray
context = ray.init(address="ray://192.168.3.224:31001")
counter = Counter.remote()
ref = counter.increment.remote()
assert ray.get(ref) == 1
time.sleep(30000000)
ray提交任务
# 创建一个client,指定远程ray集群的head地址
client = JobSubmissionClient("http://xxxx.xxx.xxx.xxx:31111")
# 创建任务的ID
id = uuid.uuid4().hex
input_params_key = f"{id}:input"
input_params_value = [1, 2, 3, 4, 5]
# 将输入参数存入redis,供远程函数job使用
# 提交一个ray job 是一个独立的ray应用程序
job_id = client.submit_job(
# 执行该job的入口脚本
entrypoint=f"python ray_compute.py {input_params_key}",
# 将本地文件上传到ray集群
runtime_env={
"working_dir": "./",
"py_modules": [test_module, stk12],
"env_vars": {"testenv": "test-1"}
},
# 自定义任务ID
submission_id=f"{id}"
)
# 输出job ID
print("job_id:", job_id)
总体设计
迁移到ray的好处是,本身基于faas函数计算可以做状态托管,同时对于资源需求量少的任务还可以按量分配。通过k8s可以走到资源的自动扩所容,并且原先的进程间数据管道交给komou代理到kafka,计算交给ray。 实现了算法框架的存算分离
使用功能对齐
-
keyby算子
- 为每个点位拆分task,分配对应的算法插件调用Ray分区函数, 可以利用ray的partition来做到键控流的逻辑分区
- 采用komou作为本地队列,依赖kafka进行通信
-
滑动窗口
通过pandas时间序列截取长度实现攒批, 然后根据transform变换做聚合
-
会话窗口
-
多sink写出
-
侧流..
- ray的OutputSplitter输出
- reduce函数写入到一个临时的function进行计算
资源调度对齐
默认情况下,Ray 的 Task 会使用 1 个逻辑 CPU,该 CPU 既负责任务调度,也用于执行计算任务;而 Ray 的 Actor 则默认使用 1 个逻辑 CPU 来进行任务调度,并默认不占用 CPU 资源来执行计算任务。
当 Task 或 Actor 在执行时,Ray 会将它调度到能够满足它的资源需求的节点上。在默认情况下,Ray Task 的资源需求相对明确,而 Ray Actor 的默认 CPU 资源需求为 0。如果不做额外设置,这会导致一种错误印象,认为 Ray Actor 不需要计算资源,从而可能导致大量 Actor被集中调度到同一个计算节点上。为更好地控制资源使用并避免潜在风险,建议在定义 Task或 Actor 时指定所需的计算资源数量。具体来说,可以在使用 ray.remote()修饰函数或类时,传递 num_cpus 和 num_gpus 参数来指定 Task 和 Actor 所需的计算资源
Autoscaler 是 Ray 实现的一个与 Kubernetes HPA 类似的特性,它可以根据集群的负载情况,自动调整集群的规模, 实现精细化的资源管理。
其需要的配置大致如下:
- 支持standalone部署 (为function计算分配细粒度的cpu和memory)
- 支持集群模式部署