附录
附录.1 加载 Janus 小程序集及小程序调度
正如第 3.1 节所解释的,Janus 小程序通过 Janus 控制器提供的网络 API 进行 JIT 编译并加载到 Janus 设备上。开发者可以使用 Janus SDK 提供的工具将开发的小程序上传到 Janus 控制器并加载到 Janus 设备,如图 10 所示。将小程序上传到 Janus 控制器并加载到 Janus 设备所需的指令被编码在一个以 YAML 格式编写的描述符文件中,其结构和属性在示例 6 中列出。
1 codelet1: 2 codelet: codelet1.o 3 hook_name: hook_name1 4 priority: codelet1_running_priority 5 runtime_threshold: codelet1_max_runtime 6 codeletN: 7 codelet: codeletN.o 8 hook_name: hook_nameN 9 priority: codeletN_running_priority10 runtime_threshold: codeletN_max_runtime11 linked_maps:12 codeletN_map:13 codelet: codelet114 map_name: codelet1_map
复制代码
示例 6: 小程序 YAML 格式
YAML 文件指定了小程序集中所有小程序的名称(ELF 格式的 eBPF 字节码对象文件),被链接到 Janus 设备中的钩子名称以及调度优先级(多个小程序可以被链接到同一个钩子)。YAML 文件为进一步配置小程序提供了额外的可选配置参数,例如,在利用 5.1 节的运行时控制机制时,为每个打补丁的小程序设置运行时阈值。
图 10: 将小程序加载到 Janus 设备的过程。
同一小程序集的小程序可以共享状态并协调执行,这在执行监测或控制操作时特别有用,这些操作需要获取跨 vRAN 堆栈不同层的事件和数据(例如,在第 4 节干扰检测的情况下)。小程序之间的状态共享是通过共享 map 实现的,为了使这种机制发挥作用,想要共享状态的小程序必须在其type
、key_size
、value_size
和max_entries
方面使用完全相同的共享 map 定义,而 map 名称可以不同。然后,开发者可以在用于加载小程序集的 YAML 描述符文件的linked_maps
部分指定要链接的 map,如示例 6 所示。当小程序集被加载时,共享 map 的内存在 Janus 设备上只被分配一次,所有共享 map 的小程序都会得到一个指向相同内存的指针,然后可以通过辅助函数调用该内存来存储和加载状态。
附录.2 灵活的 Janus 输出格式
输出格式可以被多个小程序复用。通过 Janus SDK,输出格式定义和小程序被分别上传到 Janus 控制器,小程序在其环形输出缓冲区 map 的定义中指定使用哪种输出格式,包括三个字段,如示例 1 的第 11-13 行所示。proto_name
字段包含名称,表示已经上传到 Janus 控制器的唯一的 protobuf 规范文件。proto_msg_name
字段表示proto_name
规范的根消息,用于通过这个环形缓冲区发送数据(一个 proto 规范可以包含多个消息定义)。最后,proto_hash
字段包含哈希值,用来确保开发小程序时使用的 protobuf 规范文件的内容与上传到 Janus 控制器的文件内容相同。
图 11: 将带有输出格式定义的 Janus 小程序集加载到 Janus 设备的过程。
图 11 说明了带有输出格式定义的小程序集的加载过程。Janus 控制器解析小程序对象(ELF)文件,识别 map 部分中JANUS_MAP_TYPE_RINGBUF
类型,然后基于 map 定义中的proto_name
值,将小程序与先前上传到控制器的一些.proto
规范文件联系起来。一旦找到规范定义,控制器将 map 的proto_hash
字段与.proto
文件的哈希值进行比较。如果匹配,控制器会给小程序的环形缓冲区 map 分配唯一的 stream-id(一个 16 字节的 UUID)。接下来,控制器自动生成编码器(encoder) 和解码器(decoder) 函数,负责对小程序发送至 Janus 输出数据收集器的proto_msg_name
类型的消息进行序列化和反序列化。控制器通过网络 API 将自动生成的编码器函数与经过验证的小程序字节码和输出 map 的 stream-id 一起发送到 Janus 设备。控制器还将 stream-id 与自动生成的解码器函数一起发送给输出数据收集器。收集器维护一个键值结构,将 stream-id(键)映射到解码器函数(值)。
每次有JANUS_MAP_TYPE_RINGBUF
类型输出 map 的小程序被加载到 Janus 设备上,一个单生产者/单消费者(SPSC)的环形缓冲区数据结构就会被创建并链接,其中小程序是生产者,输出线程是消费者。如图 12 所示,每次小程序调用janus_ringbuf_output()
辅助函数(例如示例 1 的第 42 行),输出数据就被推送到环形缓冲区。输出线程消费这些数据,并调用相应的编码器函数对其进行序列化。在通过 UDP 将序列化数据发送到输出收集器之前,会附加一个头域,其中包括 16 字节的 stream-id 和 2 字节的序列号。一旦输出收集器收到输出信息,就会将 stream-id 与对应的解码器函数相匹配,用来反序列化该信息。最后,反序列化的消息被转换为 JSON 格式,然后可以被输送到流水线上的其他组件(如存储、ML 处理等)。
图 12: Janus 小程序输出数据。
附录.3 编写 Janus 钩子函数
为了简化编写 Janus 钩子的过程,Janus SDK 提供了一套用于声明和运行新钩子的宏。简而言之,调用DECLARE_JANUS_HOOK()
宏,并将新钩子的名称、传递给钩子的小程序上下文类型、钩子函数签名(名称和参数类型)以及用于填充将被传递给小程序的上下文赋值列表作为输入参数。示例 7 是示例 1 中小程序的钩子示例。
1 DECLARE_JANUS_HOOK(fapi_dl_config_req , 2 struct janus_ran_fapi_ctx ctx , 3 ctx , 4 HOOK_PROTO ( 5 nfapi_dl_config_request_pdu_t * dl_config_req , 6 int ctx_id , 7 int frame , 8 int subframe , 9 int cell_id ,10 int fapi_list_size11 ) ,12 HOOK_ASSIGN (13 ctx.ctx_id = ctx_id ;14 ctx.cell_id = cell_id ;15 ctx.slot = subframe ;16 ctx.frame = frame ;17 ctx.data = ( void *) dl_config_req ;18 ctx.data_end = ( void *) ( dl_config_req + fapi_list_size ) ;19 )20 )
复制代码
示例 7: 示例 1 中小程序的钩子定义
基于这些输入,宏会自动生成构成钩子 API 的函数模板代码,包括钩子加载和卸载小程序的函数,以及运行与钩子链接的所有小程序的函数。表 5 显示了示例 7 自动生成的函数列表。
表 5: 示例 7 中通过DECLARE_JANUS_HOOK()
宏自动生成的函数
一旦声明了钩子,开发者可以通过DEFINE_JANUS_HOOK()
这个宏来实例化钩子,将钩子名称作为参数传入 vCU/vDU 代码中。最后,通过调用自动生成的函数hook_#hook_name()
,可以在代码的任何地方调用该钩子,其中#hook_name
是声明该钩子时使用的名称。例如,在示例 7 中,会生成钩子hook_fapi_dl_config_req()
。
附录.4 基于 Janus 进行推理
接下来我们详细解释 Janus 如何通过 map 使用更复杂的 ML 模型进行推理。示例 8 中显示了一个简单的小程序例子,用于第 4 节中讨论的随机森林模型。这个小程序对一个预先训练好的模型进行推理,然后返回。
1 struct janus_load_map_def SEC("maps") model_map = { 2 .type = JANUS_MAP_TYPE_ML_MODEL , 3 .max_entries = 16 , 4 .ml_model = "random_forest", 5 }; 6 7 struct features { 8 int f1 ; 9 int f2 ;10 int f3 ;11 int o1 ;12 };1314 SEC("janus_ran_fapi")15 uint64_t bpf_prog(void *state) {1617 struct features feats ;18 int res ;1920 feats.f1 = 1;21 feats.f2 = 1;22 feats.f3 = 1;2324 /* We store inference result in output */25 res = janus_model_predict(&model_map , &feats);2627 if (res) return 1;2829 return 0;30 }
复制代码
示例 8: ML 模型使用示例
加载 ML 模型 -- 从示例 8 第 2 行可以看出,我们定义了一个JANUS_MAP_TYPE_ML_MODEL
类型的 map,该模型必须由控制器以序列化格式从一个名为random_forest
的输入文件中加载(第 4 行)。序列化的模型以 char 数组的形式表示,并获取 janus 所需的所有重要信息,以便在内存中重新创建训练好的模型。例如,对于随机森林模型,序列化包含关于模型类型(随机森林)、估算器(树)数量、对每个估算器的每个节点进行检查的条件以及树的叶子节点的推理值等信息。Janus 只支持一组预先确定的模型类型(目前是随机森林和 SVM),每个模型都有自己的序列化参数集。在加载小程序的过程中,验证器将检查加载的序列化模型是否有效,包括检查模型类型以及模型参数的有效性(即模型是否可以在内存中重构)。如果任何一项检查失败,那么小程序就不会被加载到 Janus 设备中。
程序内推理 -- 加载的模型希望有一组输入特征以及确定数量和大小的输出,输入特征和输出所需的内存在 ML 模型 map 定义中指定。比如示例 8 有 3 个输入特征和 1 个输出(第 7-12 行),总内存大小为 16 字节(第 3 行)。输入特征和输出的确切内存布局取决于训练好的模型,因此是由小程序决定的。有了输入特征,可以通过调用辅助函数janus_model_predict()
进行推理,如第 25 行所示。
附录.5 实时运算
还有更多为 Janus 所作的优化的细节,以确保实时性能:
输出 map -- 对于每个输出流,创建一个无锁的单生产者/单消费者环形缓冲区,将数据从小程序推送到输出线程,避免任何系统调用(见图 2)。这确保了小程序永远不会被抢占,并且在最坏情况下可能只是丢掉多余的数据包。
内存分配 -- Janus 使用预先分配的内存进行操作(加载小程序的 JIT 代码,map,数据输出等)。它依赖 DPDK mempools 和 Mbufs 在无锁模式下运行(使用rte_stack
mempool),从而确保使用同一 mempool 的多个线程在被抢占的情况下不会影响其他线程的性能,并且最小化在 fastpath 中访问内存的抖动。
Janus map 的并发性 -- 根据设计,Janus map 不使用锁,以保证时间敏感的 vRAN 功能的实时性能,因此是非线程安全的,并可能会导致并发性问题(例如,在多实例工作线程代码中调用钩子)。然而,根据我们使用商业级 vRAN 功能的 Janus 的经验,大多数情况下,只要某种上下文 ID 作为钩子上下文的一部分被传递,以识别哪个实例正在调用钩子(例如,哪个 CPU 核心,哪个工作线程等),就可以用无锁的方式编写小程序。我们正计划在未来适当的时候通过采用线程安全的 map 解决这一限制。
加载/卸载小程序 -- 我们通过基于用户空间实现的 RCU(Read-Copy-Update)[65]将小程序加载(卸载)到 Janus 钩子上。RCU 可以实现无锁访问共享数据结构,代价是写入/更新时间[69, 70]更长。这对 Janus 很适合,执行小程序的快速 vRAN 线程会读取数据,而更新钩子小程序列表的(非实时)线程写入/更新该数据。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind