尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CANN Runtime日志分级过滤机制与排障实践:从源码到落盘

CANN Runtime日志分级过滤机制与排障实践:从源码到落盘 CANN Runtime日志系统集成日志分级过滤输出的实现与源码拆解先说一个我自己调试NPU任务时的典型场景。你写了一个基于CANN的推理程序在Atlas训练卡上跑起来结果第一条aclrtLaunch就返回了错误码。这时候大多数人会先怀疑算法写错了再怀疑算子实现不对忙活一两个小时最后才想起来去看日志。等你回头翻日志才发现一条包含关键错误码的记录早就躺在那里了只是默认级别下没打印出来。CANN Runtime的日志系统就是这么个角色平时透明出了问题它就是第一个抓手。这篇东西适合谁看刚拿到CANN环境、还在摸索日志从哪来的新手做算子开发需要频繁跟Runtime打交道的人以及把CANN接入到自研推理框架、需要把日志系统和业务日志统一管理的后端工程师。我会从日志在Runtime里到底记了什么开始讲然后拆分级过滤的源码套路最后给出一套可直接落地的日志集成和排障方案。文章里所有内容都是我基于CANN Runtime日志系统的通用机制和开源社区中常见的日志设计经验整理的如果你手头特定版本的日志目录或API名称略有不同以实际环境为准。1. 一条失败日志背后CANN Runtime到底记了什么1.1 一次任务下发失败日志能还原出完整现场先看一个真实例子。一次单算子调用失败默认配置下你看到的日志可能只有一行[ERROR] aclrtLaunch task failed, task id 35, ret 0x500002这一行除了告诉你任务ID和错误码之外什么都没有。你把日志级别开到DEBUG之后同样一次失败日志会变成一长串从API入口进入、算子描述解析、任务队列申请、内存拷贝、流同步一直到最后任务执行失败返回。你会发现一个特别有意思的地方真正的失败点往往不是第一个报错的函数而是前一个WARNING级别日志里那个“看起来还能继续”的异常分支。比如日志里会出现这样的递进关系[INFO] enter aclrtLaunch, stream0x7f...进入启动接口[DEBUG] parse op desc, op type Reshape解析算子描述一切正常[WARNING] aicore task queue busy, retry 1 time任务队列繁忙这是第一次重试[ERROR] timeout after 3 retries, task queue still full, ret0x500002重试耗尽任务下发失败看到没有如果只开默认日志级别第1行和第2行通常被过滤掉第3行WARNING可能被输出但很容易忽略最后只有一行ERROR。日志分级的第一个价值就在这里它决定了你看到的是“失败的结论”还是“失败的全过程”。而DEBUG信息中记录的完整调用链能直接告诉你问题出在算子下发环节还是任务队列环节根本不需要猜。1.2 日志系统的三个职责诊断、观测、审计做日志系统设计时我一直把职责拆成三块CANN Runtime的日志体系也是按这个逻辑来的。第一是诊断。Runtime运行在用户态但它管理的对象是AI Core、内存、事件、流这些硬件资源。一旦任务异常只有日志能告诉你硬件侧到底发生了什么。比如NPU上发生了指令异常错误码从驱动层上报上来之后Runtime会把它翻译成可读的字符串并附带上对应的task id和stream id这就是后续排查的锚点。第二是观测。你可以在日志里看到算子下发耗时、任务队列深度变化、内存池碎片情况。这些信息平时不用打开但在做性能分析时通过调整日志级别把它们释放出来就能清楚看到一条推理链路的瓶颈在哪里。我习惯在压测的时候临时把Runtime日志开到INFO级别观察每个task的提交和执行间隔再配合profiling数据交叉验证。第三是审计。多卡环境下谁在什么时候创建了流、申请了多大的内存、调用了哪个接口这些关键动作在有完整审计需求的场景里必须可回溯。CANN Runtime日志里通常会给每条关键记录带上时间戳、进程ID、线程ID这几个维度正好满足这类需求。理解了这三个职责就能明白为什么日志不能做成“一把梭”什么都打。每个模块的日志点必须区分轻重API入口这种高频函数INFO级别记录进出时间内存分配这种资源操作WARNING级别才记录失败分支真正到硬件交互这种低频关键动作才默认记录。这就是分级的意义。2. 日志框架的分层设计从应用层到驱动层的数据管道2.1 日志不是一层而是一条管道很多人以为日志就是printf换个地方输出实际上CANN Runtime的日志管道是分层的。一次完整日志的产生从应用调用层一路走到最终落盘中间至少过四层第一层是应用调用层。你调用的aclrtMalloc、aclrtLaunch这些接口在进入真正的Runtime实现之前会先经过API层。这一层的日志主要是入口参数、返回值和耗时统计特征是调用频率极高、并发度大所以这层的日志点设计必须格外小心不能每进一个函数就格式化一行字符串。第二层是Runtime服务层。这一层记录的是真正的执行逻辑比如任务调度、流同步、事件处理。这层产生的日志字段最多包括任务ID、流ID、设备ID、线程ID信息量大是排障时最主要的信息来源。第三层是驱动层。驱动负责和硬件真正打交道所以很多硬件相关的错误码、DMA传输状态、AI Core异常信息都从这一层产生。驱动层日志通常不能像应用层那样随意输出到用户态文件往往走独立的驱动日志通道。第四层是硬件事件层。NPU设备本身能够产生错误记录比如ECC错误、超温告警。这些信息最终会通过事件上报机制传到主机侧由Runtime统一写入日志。了解这条管道之后再去看错误定位的思路就会清晰很多。遇到问题先判断错误发生在这四层中的哪一层再决定去哪里看日志。比如接口返回错误码但应用层日志空白那问题多半出在第二层之后的逻辑里错误码在驱动层产生那对应日志就要往驱动目录里找。2.2 分级模型与过滤机制不是只有DEBUG和INFOCANN Runtime的日志级别从严格程度从高到低大概可以分成ERROR错误信息任务失败或资源不可用肯定要记录WARNING存在异常但不影响当前执行的告警比如重试、降级、超时前兆INFO关键流程节点比如成功创建流、成功加载模型DEBUG详细执行过程比如每个接口的入参、每个算子的调度细节用于问题定位这种分级模型的核心思路是“级别越高日志越少”。也就是说ERROR信息永远最少但最重要DEBUG信息最多但大多数时候没人看。实际开发里级别过滤有一个关键的设计决策先判断后格式化。就是说先比较当前级别和日志点的级别如果日志点级别高于全局级别直接跳过整个日志函数连格式化字符串都不做。这个判断要放在日志调用的最前面否则每次调用都重新取时间、算线程号性能直接崩掉。用一段简化的伪代码演示这个判断逻辑设计上都差不多// 简化描述日志级别过滤的核心判断 enum LogLevel { LOG_LEVEL_DEBUG 0, LOG_LEVEL_INFO 1, LOG_LEVEL_WARN 2, LOG_LEVEL_ERROR 3 }; thread_local uint32_t tls_cached_level_mask 0; std::atomicuint32_t g_log_level_mask; // 每次写日志前调用这个函数判断是否要记 bool should_log(LogLevel level) { // 先读线程缓存过滤逻辑就是一个位运算比较成本极低 if ((tls_cached_level_mask (1u level)) 0) { return false; } // 做模块级别的开关判断 if (!module_enabled(current_module_id, level)) { return false; } return true; }注意这里为什么要加一个tls_cached_level_mask。全局级别可能被其他线程修改每次判断都读全局变量存在cache miss代价高。而每个线程在进入日志模块时同步一次级别掩码到线程本地变量之后每次判断都只访问线程本地存储这在高频日志场景下能省下不少开销。级联判断的另一个好处是模块开关放在级别判断之后因为大多数日志点连级别过滤都过不去根本不需要查模块开关这样能减少一次查表。2.3 落盘与上报文件、环形缓冲、事件通道日志过滤通过之后才是真正写日志的阶段。这部分的实现同样有讲究。文件通道是系统的主干。Runtime会把符合条件的日志写到宿主机的一个固定目录下。常见的日志根目录是$HOME/ascend/log里面按日期分子目录文件名一般包含进程名、进程号、时间戳这些信息。不同进程的日志分文件存放这也是多进程调试时能快速定位的基础。文件写入往往带缓冲避免每个日志行都触发一次系统调用但这也带来一个问题突发断电或进程崩溃时缓冲区的日志可能丢。所以很多实现的折中方案是ERROR级别的日志直接fflush其他级别延迟写。事件通道是给关键错误用的。当设备侧发生硬件故障或者Runtime内部发生不可恢复错误时主日志文件可能都没来得及写事件通道会以最快的路径把错误记录写到一个独立文件。这个文件一般很小内容精简但关键时刻比主日志更可靠。环形缓冲是给高性能场景用的。在算子下发的高频路径上每次都写文件会导致IO成为瓶颈。有些版本会把日志先写入一块固定大小的环形内存区域日志行满了再一次性合并写出。这样做的代价是如果进程崩溃环形缓冲区里未落盘的历史日志会丢一批所以生产环境里是否启用环形缓冲要结合数据重要性来权衡。3. 源码拆解日志分级过滤与输出的几个关键套路3.1 级别掩码与阈值比较哪种过滤方式更适合Runtime过滤级别最常用的两种思路一种是阈值比较一种是位掩码判断。阈值比较就是维护一个g_min_level判断level g_min_level才写逻辑最简单代码好理解但每判断一次都要读全局变量且只支持一个维度。位掩码判断则是维护一个32位整数每一位对应一个级别判断时做(mask (1u level))运算支持多个级别自由组合。比如你可以单独关闭DEBUG而保留INFO也可以只留ERROR和DEBUG这在处理线上问题时特别有用只开启某个级别而不让低级别日志刷屏。CANN Runtime这类复杂系统里纯阈值判断是不够的因为有时候你不只想按级别过滤还要按模块过滤。位掩码天然支持多维度组合所以我在设计自研日志模块时也用了相同思路。模块过滤的实现方式通常是维护一个模块ID到开关状态的映射表。模块ID就是预先给各个Runtime模块分配的编号比如任务调度模块、内存模块、设备管理模块各占一个ID。判断函数先做全局级别检查通过之后再查模块ID的开关。这里有个细节模块开关表一般放在共享内存里这样外部工具能动态调整某个模块的日志级别不需要重启进程对线上故障排查非常有用。3.2 日志行组装与上下文补全延迟格式化是关键日志级别判断通过之后接下来就是组装日志行。很多人写日志代码都是直接sprintf拼字符串这在业务代码里没问题但在Runtime这种底层框架里是不行的。高效日志系统的标准动作是延迟格式化。思路是这样的日志点先传入级别、模块、格式化字符串和参数列表框架判断完过滤条件后再执行真正的字符串格式化。这样做的原因很简单级别过滤不过的日志点根本不用执行格式化而不格式化的代价远远小于格式化本身。格式化涉及内存分配、数字转字符串、时间格式化每一步都是CPU周期。上下文补全则是另一个容易被忽视的点。一条日志要真正有用不能只记录一句话还需要带上环境信息。常见的上下文包括时间戳精度至少到毫秒线程ID定位并发问题必需进程ID多进程场景下区分来源设备ID多卡环境下定位异常设备流程ID或任务ID把同一个推理请求的多条日志串起来我见过很多日志系统格式化字符串写得很好但忘了打线程ID和任务ID结果日志里一条“内存分配失败”连是哪个线程触发的都看不出来。这在Runtime的并发场景下毫无意义。所以你集成日志时不管用官方日志文件还是自己写日志插件这五个字段一个都不能少。3.3 过滤链与采样控制防止日志风暴的关键设计真正上过生产环境的人都知道日志系统最大的敌人不是级别而是日志量。调试时偶尔开一下DEBUG没问题但如果业务高峰期误开了DEBUG级别日志量可能瞬间把磁盘塞满甚至拖垮整个进程。所以在过滤链设计中除了级别、模块两个维度还需考虑关键字过滤和采样率控制。一条相对完整的过滤链是这样的第一关全局级别掩码。这是最便宜的一关一位位运算解决问题。第二关模块开关。在上述基础上检查当前模块是否开启。第三关关键字/正则过滤。对日志内容的子字符串做检查适合线上精确过滤同一个错误码。第四关采样率。超过一定频率的日志按比例记录比如同一错误码每秒最多写5条。每一关的过滤条件都必须从便宜到昂贵排列。级别掩码开销为纳秒级正则匹配开销为微秒级如果顺序反了级别判断没做就先去做正则匹配那等于每次日志都白做一次昂贵操作。采样控制还有一种进阶玩法是“先聚合后落盘”。同一错误码在一秒内出现一万次正常日志系统会写一万条重复记录而带聚合功能的系统会合并成一条[ERROR] task fail, ret 0x500002, count 10000, first_seen 12:00:01, last_seen 12:00:10这一条日志既保留了所有有效信息又不会因为量大把磁盘写爆。在做Runtime日志的时候我强烈建议集成层加上这个聚合能力效果立竿见影。4. 从环境变量到代码集成一套可直接落地的实操方案4.1 环境变量怎么配日志级联控制的两个核心开关CANN Runtime的日志控制最常用的环境变量有两个。第一个是ASCEND_GLOBAL_LOG_LEVEL用于控制全局日志级别。常见的取值是0DEBUG、1INFO、2WARNING、3ERROR数值越大日志越少。调试阶段习惯设成0线上运行推荐设为2或3避免日志量过大。第二个是ASCEND_SLOG_PRINT_TO_STDOUT控制日志是否同步输出到标准输出。取值为1时会同时打到终端方便本地调试直接看输出线上环境建议设为0只写文件避免stdout被日志淹没影响其他进程的输出处理。我自己调试的标配是export ASCEND_GLOBAL_LOG_LEVEL0 export ASCEND_SLOG_PRINT_TO_STDOUT1跑完问题场景定位到原因之后立刻恢复到2和0。日志级别开太低跑久了磁盘占用会很夸张这点一定要提醒新同学。除了环境变量代码里也可以在Runtime初始化阶段主动设置日志级别。具体API名称不同版本略有差异思路是一致的在aclInit之前完成全局日志级别初始化确保后续所有模块都能读到正确的级别配置。4.2 把CANN日志集成到自己的日志体系实际项目中CANN往往只是整个系统的一个组件你的框架可能已经有一套日志系统比如spdlog或者自研的统一日志。这时你面临一个集成问题CANN Runtime的日志和业务日志要分成两套吗我的建议是保留CANN日志的独立文件但增加一套转发机制。独立文件的好处是CANN日志的格式稳定错误码和任务ID齐全排障时直接用官方日志分析工具或脚本处理不会因为业务日志格式而污染。转发机制则是为了运维监控把ERROR级别的日志抽出来同步到统一日志平台方便告警和分析。如果你不想转发只想在自有日志里快速看到Runtime的错误最省事的做法是设置ASCEND_SLOG_PRINT_TO_STDOUT1然后进程启动时把stdout重定向到自己的日志管道里。代价是日志结构会被破坏级别字段、函数名、行号这些信息都混在文本里后续解析麻烦。所以我只把它当临时调试手段不建议作为长期集成方案。在一个C项目里做日志封装习惯上是写一个独立Logger把环境变量读取、级别转换、格式化、输出这几件事封装起来核心逻辑类似// 简化示例实现一个Runtime日志封装将日志转发到自有系统 class RuntimeLogger { public: explicit RuntimeLogger(bool enableConsole) { // 从环境变量读取全局级别 const char* levelEnv std::getenv(ASCEND_GLOBAL_LOG_LEVEL); if (levelEnv) { global_level_ static_castLogLevel(std::atoi(levelEnv)); } // 输出到标准输出的开关 console_enabled_ enableConsole || (std::getenv(ASCEND_SLOG_PRINT_TO_STDOUT) ! nullptr); } void log(LogLevel level, const char* file, int line, const char* format, ...) { if (level global_level_) return; // 级别过滤第一关 char buffer[4096]; va_list args; va_start(args, format); vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); if (console_enabled_) { fprintf(stdout, [%s] %s:%d %s\n, levelToString(level), file, line, buffer); } // 转发到统一日志系统的动作在这里做 // 比如 spdlog 的 error(...) 或 info(...) } private: LogLevel global_level_{LogLevel::kWarning}; bool console_enabled_{false}; };这段代码虽然简单但核心思想是完整了先做级别过滤再做格式化输出前保存文件和行号。实际生产系统中我会再加环形缓冲、异步落盘和滚动文件逻辑不变复杂度主要在并发控制上。4.3 日志轮转与性能开销控制集成了日志之后紧跟着要解决两个问题日志文件膨胀和性能开销。日志轮转方面常见的做法是按大小或按天数切分。按天数切分很简单每天一个目录按大小切分需要关注单文件上限一般建议单文件不超过512MB超过则自动切换到下一个文件并保留最近N个文件。这块官方日志系统本身有基本保障但在容器场景要注意日志目录的挂载否则容器重建后日志会直接丢失。性能开销方面我的经验数据可以参考INFO级别全开对推理性能的影响约在1%到3%之间基本无感DEBUG级别全开影响会显著增大可能到10%以上只建议在测试环境临时使用。所以生产上强烈建议保持在WARNING及以上同时用模块开关只打开需要排查的模块。一条日志从产生到落盘成本大头在格式化、时间获取和文件IO三个操作都要做取舍。比如时间精度要求不高的场景用毫秒而不是微秒能省不少CPU周期。5. 集成CANN Runtime日志时踩过的坑与排查路径5.1 常见问题速查表我从实践中踩出来的清单日志系统的坑往往在调试时才暴露这里整理一份我实际遇到过的排查表按出现频率排序现象可能原因处理思路设置了ASCEND_GLOBAL_LOG_LEVEL0仍看不到DEBUG日志环境变量在进程启动后才设置或存在多个CANN版本冲突确认环境变量在进程启动前已export用env命令检查查看实际加载的Runtime库属于哪个路径容器里跑任务找不到日志文件日志目录没有挂载到宿主机或者容器内HOME目录被覆盖将日志目录显式挂载到宿主机持久化路径确认HOME环境变量符合预期日志时间与本地时间相差若干小时时间戳采用UTC时区使用date命令对比处理日志时统一按UTC换算避免歧义多进程写日志导致文件混乱所有进程共用一个日志文件名按进程号区分日志文件排查时优先用grep按PID过滤日志文件占用过大撑爆磁盘DEBUG级别开启时间过长、无轮转策略及时关闭DEBUG配置轮转按文件大小定期清理ERROR日志出现但业务侧无感知错误发生在驱动或硬件层通过事件通道上报同时查看驱动日志和设备事件记录不能只看应用层文件5.2 排查问题时的四条高效路径以我排查NPU任务异常的经验日志定位有一套相对固定的流程第一条路径先总体后局部。把全局日志级别开到WARNING跑一边问题场景看有没有ERROR或WARNING记录。通过错误码锁定大致模块比如是内存分配、设备管理、还是任务调度。第二条路径单独放大模块。确认模块后只打开这个模块的DEBUG级别其他模块保持WARNING以上这样既能看到执行细节又不会被其他模块的日志淹没。这个操作在长任务调试时特别好用。第三条路径按任务ID过滤。很多Runtime日志带有task id、stream id先用grep把同一条task的日志串出来按时间顺序排列基本能还原一条算子从创建到完成的完整生命周期。串出来之后重点看最后一个非ERROR日志和第一个ERROR日志之间的衔接失败原因通常就在那个夹缝里。第四条路径跨层交叉验证。如果单看Runtime日志定位不到问题就需要往上翻业务调用链往下翻驱动日志把三层日志按时间对齐找到第一个不一致的地方。比如Runtime日志显示任务已提交但驱动日志显示事件从未触发说明问题出在硬件侧或者驱动侧需要继续往下排查。这套流程不一定每次都能一击命中但至少能帮你把问题范围缩小到几个函数之内比漫无目的地翻日志高效得多。最后再分享一个实际体会日志系统看起来是个不起眼的模块但它做得好不好直接决定了线上出问题时你能在多短时间内恢复。CANN Runtime的日志分级过滤逻辑上并不复杂真正要花心思的地方在于怎么把性能损耗降到最低怎么在日志量和信息量之间找到平衡。我每次接到一个新的Runtime环境第一件事就是把日志编好把过滤链跑通把常见错误的排查表建起来。等到真出问题时你会发现前期的投入全都能赚回来。如果后续有条件你还可以把你自己的业务日志同样按“全局级别掩码 模块开关 关键字过滤 采样控制”这套思路重写一遍配合脚本按task id自动切段日志调试体验会有质的提升。写脚本时注意用时间戳配PID做文件切分不要单纯依赖文件名因为同一个进程在不同设备上可能同时产生多条日志分不清设备ID的话排查起来还是会绕远路。
返回列表