tinyml-ml-tf-merge-6

265 阅读1小时+

Tinyml:TensorFlow Lite 深度学习(七)

原文:Tinyml: Machine Learning with Tensorflow Lite

译者:飞龙

协议:CC BY-NC-SA 4.0

第十四章:设计你自己的 TinyML 应用程序

到目前为止,我们已经探讨了重要领域如音频、图像和手势识别的现有参考应用。如果你的问题类似于其中一个示例,你应该能够调整训练和部署过程,但如果不明显如何修改我们的示例以适应呢?在本章和接下来的章节中,我们将介绍为一个没有易于起点的问题构建嵌入式机器学习解决方案的过程。你对示例的经验将为创建自己的系统奠定良好基础,但你还需要了解更多关于设计、训练和部署新模型的知识。由于我们平台的约束非常严格,我们还花了很多时间讨论如何进行正确的优化,以适应存储和计算预算,同时不会错过准确性目标。你肯定会花费大量时间尝试理解为什么事情不起作用,因此我们涵盖了各种调试技术。最后,我们探讨了如何为用户的隐私和安全建立保障措施。

设计过程

训练模型可能需要几天或几周的时间,引入新的嵌入式硬件平台也可能非常耗时——因此,任何嵌入式机器学习项目最大的风险之一是在你有可用的东西之前时间耗尽。减少这种风险的最有效方法是尽早回答尽可能多的未解决问题,通过规划、研究和实验。对训练数据或架构的每次更改很容易涉及一周的编码和重新训练,部署硬件更改会在整个软件堆栈中产生连锁反应,需要大量重写先前有效的代码。你可以在开始时做的任何事情,以减少后续开发过程中所需更改的数量,可以节省你本来会花在这些更改上的时间。本章重点介绍了我们建议用于在编写最终应用程序之前回答重要问题的一些技术。

你需要微控制器,还是更大的设备可以工作?

你真正需要回答的第一个问题是,你是否需要嵌入式系统的优势,或者至少对电池寿命、成本和尺寸的要求可以放松,至少对于一个初始原型来说。在具有完整现代操作系统(如 Linux)的系统上编程比在嵌入式世界中开发要容易得多(也更快)。你可以以低于 25 美元的价格获得像树莓派这样的完整桌面级系统,以及许多外围设备,如摄像头和其他传感器。如果你需要运行计算密集型的神经网络,NVIDIA 的 Jetson 系列板卡起价为 99 美元,并带来了强大的软件堆栈,体积小。这些设备的最大缺点是它们会消耗几瓦的电力,使它们的电池寿命在几小时或几天左右,具体取决于能量存储的物理尺寸。只要延迟不是一个硬性约束,你甚至可以启动尽可能多的强大云服务器来处理神经网络工作负载,让客户端设备处理界面和网络通信。

我们坚信能够在任何地方部署的力量,但如果你试图确定一个想法是否有效,我们强烈建议尝试使用一个易于快速实验的设备进行原型设计。开发嵌入式系统非常痛苦,所以在深入之前尽可能梳理出应用程序的真正需求,你成功的机会就越大。

举个实际的例子,想象一下你想要建造一个设备来帮助监测羊的健康。最终产品需要能够在没有良好连接的环境中运行数周甚至数月,因此必须是嵌入式系统。然而,在开始时,你不想使用这种难以编程的设备,因为你还不知道关键细节,比如你想要运行哪些模型,需要哪些传感器,或者根据你收集的数据需要采取什么行动,而且你还没有任何训练数据。为了启动你的工作,你可能会想找一个友好的农民,他们有一小群在易于接近的地方放牧的羊。你可以组装一个树莓派平台,每晚从每只被监测的羊身上取下来自己充电,然后建立一个覆盖放牧区域范围的室外 WiFi 网络,这样设备就可以轻松地与网络通信。显然,你不能指望真正的客户去做这种麻烦的事情,但通过这种设置,你将能够回答许多关于你需要构建什么的问题,尝试新的模型、传感器和形态因素将比嵌入式版本快得多。

微控制器很有用,因为它们可以按照其他硬件无法做到的方式进行扩展。它们便宜、小巧,几乎不需要能量,但这些优势只有在实际需要扩展时才会发挥作用。如果可以的话,推迟处理扩展的问题,直到绝对必要,这样你就可以确信你正在扩展正确的东西。

理解可能性

很难知道深度学习能够解决什么问题。我们发现一个非常有用的经验法则是,神经网络模型擅长处理人们可以“眨眼间”解决的任务。我们直觉上似乎能够瞬间识别物体、声音、单词和朋友,而这些正是神经网络可以执行的任务。同样,DeepMind 的围棋解决算法依赖于一个卷积神经网络,它能够查看棋盘并返回每个玩家处于多强势位置的估计。然后,该系统的长期规划部分是基于这些基础组件构建的。

这是一个有用的区分,因为它在不同种类的“智能”之间划定了界限。神经网络并不自动具备规划或像定理证明这样的高级任务的能力。它们更擅长接收大量嘈杂和混乱的数据,并稳健地发现模式。例如,神经网络可能不是指导牧羊犬如何引导羊群穿过大门的好解决方案,但它很可能是利用各种传感器数据(如体温、脉搏和加速度计读数)来预测羊羊是否感到不适的最佳方法。我们几乎无意识地执行的判断更有可能被深度学习覆盖,而不是需要明确思考的问题。这并不意味着那些更抽象的问题不能通过神经网络得到帮助,只是它们通常只是一个更大系统的组成部分,该系统使用它们的“本能”预测作为输入。

跟随他人的脚步

在研究领域,“文献回顾”是一个相对宏大的名字,用于指阅读与你感兴趣的问题相关的研究论文和其他出版物。即使你不是研究人员,当涉及深度学习时,这也是一个有用的过程,因为有很多有用的关于尝试将神经网络模型应用于各种挑战的账户,如果你能从他人的工作中获得一些启示,你将节省很多时间。理解研究论文可能是具有挑战性的,但最有用的是了解人们在类似问题上使用了什么样的模型,以及是否有任何现有的数据集可以使用,考虑到收集数据是机器学习过程中最困难的部分之一。

例如,如果你对机械轴承的预测性维护感兴趣,你可以在 arxiv.org 上搜索“深度学习预测性维护轴承”,这是机器学习研究论文最受欢迎的在线主机。截至本文撰写时,排名第一的结果是来自 2019 年的一篇综述论文,“用于轴承故障诊断的机器学习和深度学习算法:综合回顾”由 Shen Zhang 等人撰写。从中,你将了解到有一个名为Case Western Reserve University 轴承数据集的标记轴承传感器数据的标准公共数据集。拥有现有的数据集非常有帮助,因为它将帮助你在甚至还没有从自己的设置中收集读数之前就进行实验。还有对已经用于该问题的不同模型架构的很好概述,以及对它们的优势、成本和整体结果的讨论。

寻找一些类似的模型进行训练

在你对模型架构和训练数据有了一些想法之后,值得花一些时间在训练环境中进行实验,看看在没有资源限制的情况下你能够取得什么样的结果。本书专注于 TensorFlow,因此我们建议你找到一个示例 TensorFlow 教程或脚本(取决于你的经验水平),将其运行起来,然后开始适应你的问题。如果可能的话,可以参考本书中的训练示例,因为它们还包括部署到嵌入式平台所需的所有步骤。

一个思考哪种模型可能有效的好方法是查看传感器数据的特征,并尝试将其与教程中的类似内容进行匹配。例如,如果你有来自轮轴承的单通道振动数据,那将是一个相对高频的时间序列,与麦克风的音频数据有很多共同之处。作为一个起点,你可以尝试将所有轴承数据转换为*.wav*格式,然后将其输入到语音训练过程中,而不是标准的语音命令数据集,带有适当的标签。然后你可能需要更多地定制这个过程,但希望至少能得到一个有些预测性的模型,并将其用作进一步实验的基准。类似的过程也适用于将手势教程适应到任何基于加速度计的分类问题,或者为不同的机器视觉应用重新训练人员检测器。如果在本书中没有明显的示例可供参考,那么搜索展示如何使用 Keras 构建你感兴趣的模型架构的教程是一个很好的开始。

查看数据

大部分机器学习研究的重点是设计新的架构;对于训练数据集的覆盖并不多。这是因为在学术界,通常会给你一个固定的预生成训练数据集,你的竞争重点是你的模型在这个数据集上的得分如何与其他人相比。在研究之外,我们通常没有现成的数据集来解决问题,我们关心的是我们为最终用户提供的体验,而不是在一个固定数据集上的得分,因此我们的优先事项变得非常不同。

其中一位作者写了一篇博客文章更详细地介绍了这一点,但总结是你应该期望花费更多的时间收集、探索、标记和改进你的数据,而不是在模型架构上。你投入的时间回报会更高。

在处理数据时,我们发现一些常见的技术非常有用。其中一个听起来非常明显但我们经常忘记的技巧是:查看你的数据!如果你有图像,将它们下载到按标签排列的文件夹中,并在本地机器上浏览它们。如果你在处理音频文件,也是同样的操作,并且听一些音频文件的选段。你会很快发现各种你没有预料到的奇怪和错误,比如标记为美洲豹的汽车被标记为美洲豹猫,或者录音中声音太微弱或被裁剪导致部分单词被切断。即使你只有数值数据,查看逗号分隔值(CSV)文本文件中的数字也会非常有帮助。过去我们发现了一些问题,比如许多数值达到传感器的饱和限制并达到最大值,甚至超出,或者灵敏度太低导致大部分数据被挤压到一个过小的数值范围内。你可以在数据分析中更加深入,你会发现像 TensorBoard 这样的工具对于聚类和其他数据集中发生的可视化非常有帮助。

另一个要注意的问题是训练集不平衡。如果你正在对类别进行分类,不同类别在训练输入中出现的频率将影响最终的预测概率。一个容易陷入的陷阱是认为网络的结果代表真实概率——例如,“是”得分为 0.5 意味着网络预测说话的单词是“是”的概率为 50%。事实上,这种关系更加复杂,因为训练数据中每个类别的比例将控制输出值,但应用程序真实输入分布中每个类别的先验概率是需要了解真实概率的。举个例子,想象一下在 10 种不同物种的鸟类图像分类器上进行训练。如果你将其部署在南极,看到一个指示你看到了鹦鹉的结果会让你非常怀疑;如果你在亚马逊看视频,看到企鹅同样会让你感到惊讶。将这种领域知识融入训练过程可能是具有挑战性的,因为你通常希望每个类别的样本数量大致相等,这样网络才能平等“关注”每个类别。相反,通常在模型推断运行后会进行一个校准过程,根据先验知识对结果进行加权。在南极的例子中,你可能需要一个非常高的阈值才能报告一只鹦鹉,但对企鹅的阈值可能要低得多。

奥兹巫师

我们最喜欢的机器学习设计技术之一实际上并不涉及太多技术。工程中最困难的问题是确定需求是什么,很容易花费大量时间和资源在实际上对于一个问题并不起作用的东西上,特别是因为开发一个机器学习模型的过程需要很长时间。为了澄清需求,我们强烈推荐绿野仙踪方法。在这种情况下,你创建一个系统的模拟,但是不是让软件做决策,而是让一个人作为“幕后之人”。这让你在经历耗时的开发周期之前测试你的假设,以确保在将它们融入设计之前对规格进行了充分测试。

这在实践中是如何运作的呢?想象一下,你正在设计一个传感器,用于检测会议室内是否有人,如果没有人在房间里,它会调暗灯光。与构建和部署运行人员检测模型的无线微控制器不同,采用绿野仙踪方法,你会创建一个原型,只需将实时视频传送给一个坐在附近房间里的人,他手里有一个控制灯光的开关,并有指示在没有人可见时将其调暗。你很快会发现可用性问题,比如如果摄像头没有覆盖整个房间,灯光会在有人仍然在场时不断关闭,或者当有人进入房间时打开灯光存在不可接受的延迟。你可以将这种方法应用于几乎任何问题,它将为你提供关于产品的假设的宝贵验证,而不需要你花费时间和精力在基于错误基础的机器学习模型上。更好的是,你可以设置这个过程,以便从中生成用于训练集的标记数据,因为你将拥有输入数据以及你的绿野仙踪根据这些输入所做的决定。

首先在桌面上让它运行起来

绿野仙踪方法是尽快让原型运行起来的一种方式,但即使在进行模型训练之后,你也应该考虑如何尽快进行实验和迭代。将模型导出并使其在嵌入式平台上运行足够快可能需要很长时间,因此一个很好的捷径是从环境中的传感器向附近的桌面或云计算机传输数据进行处理。这可能会消耗太多能量,无法成为生产中可部署的解决方案,但只要你能确保延迟不会影响整体体验,这是一个很好的方式来获取关于你的机器学习解决方案在整个产品设计背景下运行情况的反馈。

另一个重要的好处是,你可以录制一次传感器数据流,然后一遍又一遍地用于对模型进行非正式评估。如果模型在过去曾经犯过特别严重的错误,而这些错误可能无法在正常指标中得到充分体现,这将尤其有用。如果你的照片分类器将一个婴儿标记为狗,即使你整体准确率为 95%,你可能也会特别想避免这种情况,因为这会让用户感到不安。

在桌面上运行模型有很多选择。开始的最简单方法是使用像树莓派这样具有良好传感器支持的平台收集示例数据,然后将数据批量复制到您的桌面机器(或者如果您喜欢,可以复制到云实例)。然后,您可以使用标准的 Python TensorFlow 以离线方式训练和评估潜在模型,没有交互性。当您有一个看起来很有前途的模型时,您可以采取增量步骤,例如将您的 TensorFlow 模型转换为 TensorFlow Lite,但继续在 PC 上针对批处理数据进行评估。在这之后,您可以尝试将桌面 TensorFlow Lite 应用程序放在一个简单的 Web API 后面,并从具有您所瞄准的外形因素的设备上调用它,以了解它在真实环境中的工作方式。

第十五章:优化延迟

嵌入式系统的计算能力有限,这意味着神经网络所需的密集计算可能比大多数其他平台花费更长的时间。由于嵌入式系统通常实时处理传感器数据流,运行速度过慢可能会导致许多问题。假设您试图观察可能仅在短暂时间内发生的事情(比如相机视野中出现的鸟)。如果处理时间太长,您可能会以太慢的速度采样传感器,错过其中一个事件。有时,通过重复观察重叠的传感器数据窗口,可以改善预测的质量,就像唤醒词检测示例在音频数据上运行一秒钟的窗口来进行唤醒词识别,但每次只将窗口向前移动一百毫秒或更少,对结果进行平均。在这些情况下,减少延迟可以帮助我们提高整体准确性。加快模型执行还可以使设备以更低的 CPU 频率运行,或在推理之间进入睡眠状态,从而降低整体能源使用量。

由于延迟是优化的一个重要领域,本章重点介绍了一些不同的技术,可以帮助您减少运行模型所需的时间。

首先确保它重要

有可能您的神经网络代码只是整体系统延迟的一小部分,加快它可能对产品的性能没有太大影响。确定是否是这种情况的最简单方法是在应用代码中注释掉对tflite::MicroInterpreter::Invoke()的调用。这个函数包含了所有的推理计算,并且会阻塞直到网络运行完毕,因此通过移除它,您可以观察它对整体延迟的影响。在理想的情况下,您可以通过计时器日志语句或分析器来计算这种变化,但正如稍后所述,即使只是闪烁 LED 并粗略估计频率差异,也足以让您对速度增加有一个大致的概念。如果运行网络推理和不运行之间的差异很小,那么从优化代码的深度学习部分中获益不大,您应该首先关注应用的其他部分。

硬件更改

如果您确实需要加快神经网络代码的速度,首先要问的问题是是否能够使用更强大的硬件设备。对于许多嵌入式产品来说,这可能是不可能的,因为通常在很早的时候或者外部已经确定了要使用哪种硬件平台,但因为从软件角度来看这是最容易改变的因素,所以值得明确考虑。如果您有选择的余地,最大的约束通常是能源、速度和成本。如果可以的话,通过更换使用的芯片来权衡能源或成本以换取速度。您甚至可能在研究中幸运地发现一个新平台,它可以在不失去其他两个主要因素的情况下提供更快的速度!

注意

当神经网络进行训练时,通常会一次发送大量的训练示例,在每个训练步骤中。这样可以进行许多计算优化,而当一次只提交一个样本时是不可能的。例如,一百张图像和标签可能会作为一个单独的训练调用的一部分发送。这些训练数据的集合称为批次

在嵌入式系统中,我们通常一次处理一组传感器读数,实时处理,因此我们不希望等待收集更大的批次再触发推理。这种“单批次”关注意味着我们无法从一些在训练阶段有意义的优化中获益,因此对云端有帮助的硬件架构并不总是适用于我们的用例。

模型改进

在切换硬件平台后,对神经网络延迟产生重大影响的最简单方法是在架构层面。如果您能够创建一个足够准确但涉及更少计算的新模型,您可以加速推断而无需进行任何代码更改。通常可以通过降低准确性来换取增加速度,因此,如果您能够从一开始就使用尽可能准确的模型开始,那么您将有更多的空间进行这些权衡。这意味着花时间改进和扩展您的训练数据在整个开发过程中可能非常有帮助,即使在看似无关的任务,如延迟优化方面。

在优化过程代码时,通常更好的做法是花时间改变代码基于的高级算法,而不是在汇编中重写内部循环。对模型架构的关注基于同样的想法;如果可以的话,最好是完全消除工作,而不是提高执行工作的速度。在我们的情况下不同的是,交换机器学习模型比在传统代码中切换算法要容易得多,因为每个模型只是一个接受输入数据并返回数值结果的功能黑盒。在收集了一组良好的数据之后,应该相对容易地在训练脚本中用另一个模型替换一个模型。您甚至可以尝试删除您正在使用的模型中的单个层并观察效果。神经网络往往具有非常良好的退化性能,因此您应该随意尝试许多不同的破坏性更改,并观察它们对准确性和延迟的影响。

估算模型延迟

大多数神经网络模型在运行时花费大部分时间在运行大型矩阵乘法或非常接近的等效操作。这是因为每个输入值必须由不同的权重缩放以获得每个输出值,因此,每个网络层的工作量大约等于每个输入值乘以每个输出值的数量。这通常通过讨论网络在单次推断运行中所需的浮点运算数(或 FLOPs)来近似。通常,一个乘加操作(通常在机器码级别是一个单指令)计为两个 FLOPs,即使您执行 8 位或更低精度的量化计算,有时也会看到它们被称为 FLOPs,尽管不再涉及浮点数。可以通过手动逐层计算网络所需的 FLOPs。例如,全连接层所需的 FLOPs 数量等于输入向量的大小乘以输出向量的大小。因此,如果您知道这些维度,您可以计算出所需的工作量。通常在讨论和比较模型架构的论文中可以找到 FLOP 的估计值,比如MobileNet

FLOPs 作为一个粗略的度量单位,用于衡量一个网络执行所需时间的多少,因为其他条件相同,涉及更少计算的模型将以与 FLOPs 差异成比例的速度运行得更快。例如,您可以合理地期望一个需要 1 亿 FLOPs 的模型比 2 亿 FLOP 版本运行速度快两倍。在实践中,这并不完全正确,因为还有其他因素,比如软件对特定层的优化程度会影响延迟,但这是评估不同网络架构的一个很好的起点。这也有助于确定对于您的硬件平台可以期望什么是现实的。如果您能在芯片上以 100 毫秒运行一个 100 万 FLOP 模型,那么您可以做出一个合理的猜测,即需要 1000 万 FLOPs 的不同模型将需要大约一秒来计算。

如何加速您的模型

模型架构设计仍然是一个活跃的研究领域,因此目前很难为初学者撰写一份好的指南。最好的起点是找到一些已经设计为高效的现有模型,然后迭代地尝试进行更改。许多模型具有特定的参数,我们可以改变这些参数以影响所需的计算量,比如 MobileNet 的深度通道因子,或者期望的输入大小。在其他情况下,您可能会查看每个层所需的 FLOPs,并尝试删除特别慢的层或用更快的替代方案替换它们(例如使用深度卷积代替普通卷积)。如果可以的话,最好查看在设备上运行时每个层的实际延迟,而不是通过 FLOPs 来估计。尽管这将需要一些在接下来的代码优化部分讨论的性能分析技术。

注意

设计模型架构是困难且耗时的,但最近已经有一些自动化这个过程的进展,比如MnasNet,使用遗传算法等方法来改进网络设计。这些方法还没有完全取代人类(它们通常需要以已知的良好架构作为起点,并且需要手动规则来确定使用的搜索空间,例如),但很可能我们将在这个领域看到快速的进展。

已经有像AutoML这样的服务,允许用户避开训练的许多细节,希望这种趋势会继续下去,这样您就能够选择最适合您的数据和效率权衡的最佳模型。

量化

运行神经网络需要进行数十万甚至数百万次计算以进行每次预测。执行这种复杂计算的大多数程序对数值精度非常敏感;否则,错误会累积并导致结果太不准确而无法使用。深度学习模型不同——它们能够在中间计算中承受大量数值精度损失,仍然能够产生整体准确的最终结果。这种特性似乎是它们训练过程的副产品,其中输入很大且充满噪音,因此模型学会了对微不足道的变化具有鲁棒性,并专注于重要的模式。

在实践中,这意味着使用 32 位浮点表示进行操作几乎总是比推断所需的精度更高。训练要求更高一些,因为它需要对权重进行许多小的更改来学习,但即使在那里,16 位表示也被广泛使用。大多数推断应用程序可以产生与浮点等效物无法区分的结果,只需使用 8 位来存储权重和激活值。鉴于我们的许多平台对这些模型依赖的 8 位乘积累加指令提供了强大的支持,这对于嵌入式应用来说是个好消息,因为这些指令在信号处理算法中很常见。

然而,将模型从浮点转换为 8 位并不简单。为了有效地执行计算,8 位值需要线性转换为实数。这对于权重来说很容易,因为我们知道每个层的范围是从训练值中得出的,因此我们可以推导出正确的缩放因子来执行转换。然而,对于激活来说就比较棘手,因为从检查模型参数和架构中并不明显每个层输出的范围是多少。如果我们选择的范围太小,一些输出将被剪切到最小值或最大值,但如果范围太大,输出的精度将比可能的精度小,我们将面临整体结果精度下降的风险。

量化仍然是一个活跃的研究课题,有许多不同的选择,因此 TensorFlow 团队在过去几年中尝试了各种方法。您可以在Raghuraman Krishnamoorthi 的“为高效推理量化深度卷积网络:白皮书”中看到一些这些实验的讨论,而量化规范则涵盖了我们现在基于经验使用的推荐方法。

我们将量化过程集中在将模型从 TensorFlow 训练环境转换为 TensorFlow Lite 图的过程中。我们过去推荐了一种量化感知训练方案,但发现这种方法难以使用,我们发现我们可以在导出时使用一些额外的技术获得等效的结果。最容易使用的量化类型是所谓的训练后权重量化。这是将权重量化为 8 位,但激活层保持浮点数的情况。这是有用的,因为它将模型文件大小缩小了 75%,并提供了一些速度优势。这是最容易运行的方法,因为它不需要任何关于激活层范围的知识,但仍然需要快速浮点硬件,这在许多嵌入式平台上并不存在。

训练后整数量化意味着模型可以在没有任何浮点计算的情况下执行,这使得它成为我们在本书中涵盖的用例的首选方法。使用它最具挑战性的部分是,在模型导出过程中需要提供一些示例输入,以便通过运行一些典型图像、音频或其他数据来观察激活层输出的范围。正如我们之前讨论过的,如果没有这些范围的估计,就无法准确地量化这些层。过去,我们使用过其他方法,比如在训练期间记录范围或在运行时捕获范围,但这些方法都有缺点,比如使训练变得更加复杂或施加延迟惩罚,因此这是最不好的方法。

如果您回顾一下我们在第十章中导出人员检测器模型的说明,您会看到我们向converter对象提供了一个representative_dataset函数。这是一个 Python 函数,用于生成激活范围估计过程所需的输入,对于人员检测器模型,我们从训练数据集中加载一些示例图像。不过,对于您训练的每个模型,您都需要弄清楚预期输入,因为每个应用程序的预期输入都会发生变化。此外,很难辨别输入在预处理过程中是如何缩放和转换的,因此创建该函数可能需要一些试错。我们希望未来能够简化这个过程。

在几乎所有平台上运行完全量化的模型都具有很大的延迟优势,但如果您支持一个新设备,您可能需要优化最计算密集的操作,以利用硬件提供的专门指令。如果您正在处理卷积网络,一个很好的起点是Conv2D操作kernel。您会注意到许多内核有uint8int8版本;uint8版本是旧的量化方法的残余物,现在不再使用,所有模型现在都应该使用int8路径导出。

产品设计

你可能不会将产品设计视为优化延迟的一种方式,但实际上这是投入时间的最佳地方之一。关键是要弄清楚你是否可以放宽对网络的要求,无论是速度还是准确性。例如,你可能想使用摄像头以每秒多帧的速度跟踪手势,但如果你有一个需要一秒钟才能运行的身体姿势检测模型,你可能可以使用更快的光学跟踪算法以更高的速率跟踪识别的点,当更准确但不太频繁的神经网络结果可用时进行更新。另一个例子,你可以让微控制器将高级语音识别委托给通过网络访问的云 API,同时保持唤醒词检测在本地设备上运行。在更广泛的层面上,你可能可以通过将不确定性纳入用户界面来放宽网络的准确性要求。用于语音识别系统的唤醒词通常是包含不太可能出现在正常语音中的音节序列的短语。如果你有一个手势系统,也许你可以要求每个序列以竖起大拇指结束以确认命令是有意的?

目标是提供尽可能好的用户体验,因此在系统的其他部分中做任何可以更容忍错误的事情,可以让你有更多的空间来权衡准确性和速度或其他需要改进的属性。

代码优化

我们将这个主题放在章节的最后,因为在优化延迟方面有其他方法是你应该首先尝试的,但传统的代码优化是实现可接受性能的重要途径。特别是,TensorFlow Lite for Microcontrollers 的代码已经被编写成在尽可能小的二进制占用空间下运行良好,因此可能有一些优化仅适用于你特定的模型或平台,你可以从中受益。这也是我们鼓励你尽可能推迟代码优化的原因之一,因为如果你更改硬件平台或使用的模型架构,许多这类改变可能不适用,因此首先确定这些事项是至关重要的。

性能分析

任何代码优化工作的基础是知道程序中不同部分运行所需的时间。在嵌入式世界中,这可能会很难确定,因为你可能甚至没有一个简单的默认计时器,即使有,记录和返回所需的信息也可能很困难。以下是我们使用过的各种方法,从最容易实现到最棘手的。

闪烁

几乎所有的嵌入式开发板上都至少有一个 LED 可以从程序中控制。如果你要测量超过半秒的时间,可以尝试在你想要测量的代码部分开始时点亮 LED,然后在之后关闭它。你可以大致估计花费的时间,使用外部秒表并手动计算在 10 秒内看到多少次闪烁。你也可以将两个开发板并排放置,分别运行不同版本的代码,通过闪烁的频率来估计哪个更快。

散弹式性能分析

在大致了解您的应用程序正常运行需要多长时间后,估计特定代码段需要多长时间的最简单方法是将其注释掉,看整体执行速度提高了多少。这被称为shotgun profiling,类比于 shotgun debugging,其中您删除大块代码以定位崩溃,当其他信息很少时。对于神经网络调试来说,这可能会非常有效,因为模型执行代码中通常没有数据相关分支,因此通过注释掉其内部实现将任何一个操作变为无操作不应该影响模型其他部分的速度。

调试日志

在大多数情况下,您应该能够从嵌入式开发板向主机计算机输出一行文本,因此这似乎是检测代码执行时机的理想方式。不幸的是,与开发机器通信本身可能非常耗时。在 Arm Cortex-M 芯片上,串行线调试输出可能需要长达 500 毫秒的时间,延迟变化很大,这使得它对于简单的日志分析方法毫无用处。基于 UART 连接的调试日志通常成本较低,但仍不理想。

逻辑分析仪

类似于切换 LED 但更精确,您可以让您的代码打开和关闭 GPIO 引脚,然后使用外部逻辑分析仪(我们过去使用过Saleae Logic Pro 16)来可视化和测量持续时间。这需要一些布线,设备本身可能很昂贵,但它提供了一种非常灵活的方式来调查程序的延迟,而无需任何软件支持超出一个或多个 GPIO 引脚的控制。

计时器

如果您有一个可以提供足够精度的一致当前时间的计时器,您可以记录您感兴趣的代码部分的开始和结束时的时间,并在之后将持续时间输出到日志中,其中任何通信延迟都不会影响结果。出于这个原因,我们考虑在 TensorFlow Lite for Microcontrollers 中需要一个平台无关的计时器接口,但我们认为这会给那些移植到不同平台的人增加太多负担,因为设置计时器可能会很复杂。不幸的是,这意味着您需要探索如何为您正在运行的芯片实现此功能。还有一个缺点是您需要在您想要调查的任何代码周围添加计时器调用,因此需要工作和计划来识别关键部分,并且您需要在探索时间去向的过程中不断重新编译和刷新。

分析器

如果您幸运的话,您将使用支持某种外部分析工具的工具链和平台。这些应用程序通常会使用来自您的程序的调试信息,以匹配他们从设备上运行您的程序时收集的执行统计信息。然后,它们将能够可视化哪些函数花费了最多时间,甚至是哪些代码行。这是了解代码中速度瓶颈所在的最快方式,因为您将能够快速探索和放大到重要的函数。

优化操作

在确保您使用尽可能简单的模型并确定哪些代码部分花费了最多时间之后,您应该看看如何加快它们的速度。神经网络的大部分执行时间应该花在操作实现内部,因为每个层可能涉及数十万或数百万次计算,因此很可能您已经发现其中一个或多个是瓶颈。

寻找已经优化的实现

TensorFlow Lite for Microcontrollers 中所有操作的默认实现都是为了小巧、易懂和可移植,而不是快速的,因此预期您应该能够通过使用更多代码行或内存的方法轻松击败它们。我们在kernels/portable_optimized 目录中有一组更快的实现,使用了第十三章中描述的子文件夹专业化方法。这些实现不应该有任何平台依赖性,但它们可能使用比参考版本更多的内存。因为它们使用子文件夹专业化,您只需传递TAGS="portable_optimized"参数即可生成一个使用这些实现而不是默认实现的项目。

如果您正在使用具有特定于平台的实现的设备,例如通过类似 CMSIS-NN 的库,并且在指定目标时它们没有自动选择,您可以选择通过传递适当的标签来使用这些非可移植版本。但是,您需要查阅平台的文档和 TensorFlow Lite for Microcontrollers 源代码树,以找到相应的内容。

编写您自己的优化实现

如果您找不到正在占用大部分时间的操作的优化实现,或者可用的实现速度不够快,您可能需要自己编写。好消息是,您应该能够缩小范围,使工作更容易。您只需要调用几种不同的输入和输出大小以及参数的操作,因此您只需要专注于使这些路径更快,而不是一般情况。例如,我们发现深度卷积参考代码在 SparkFun Edge 开发板上的语音唤醒示例的第一个版本中占用了大部分时间,并且整体运行速度太慢,无法使用。当我们查看代码时,我们发现卷积滤波器的宽度始终为八,这使得可以编写利用该模式的一些优化代码。我们可以使用 32 位整数并行获取四个输入值和四个字节中保存的权重。

要开始优化过程,请使用前面描述的子文件夹专业化方法在kernels根目录中创建一个新目录。将参考内核实现复制到该子文件夹中,作为您代码的起点。为确保构建正确,请运行与该操作相关的单元测试,并确保它仍然通过;如果您传递了正确的标签,它应该使用新的实现:

make -f tensorflow/lite/micro/tools/make/Makefile test_depthwise_conv_\
  test TAGS="portable_optimized"

然后建议为您的操作添加一个新的测试到单元测试代码中,该测试不检查正确性,只报告执行操作所需的时间。拥有这样的基准测试将帮助您验证您的更改是否按照您的预期提高了性能。对于您在分析中看到速度瓶颈的每种情况,您应该为每种情况都有一个基准测试,具有与模型中该点的操作相同的大小和其他参数(尽管权重和输入可以是随机值,因为在大多数情况下,数字不会影响执行延迟)。基准测试代码本身将需要依赖本章前面讨论的一种性能分析方法,最好使用高精度计时器来测量持续时间,但如果没有,至少切换 LED 或逻辑输出。如果您的测量过程的粒度太大,您可能需要在循环中多次执行操作,然后除以迭代次数以捕获实际所需的时间。在编写基准测试后,记录在您进行任何更改之前的延迟,并确保它大致与您从分析应用程序中看到的相匹配。

有了代表性的基准测试数据,现在您应该能够快速迭代潜在的优化。一个很好的第一步是找到初始实现的最内部循环。这是代码中将被最频繁运行的部分,因此对其进行改进将比算法的其他部分产生更大的影响。通过查看代码并找到最深度嵌套的for循环(或等效部分),您应该能够识别出这一部分,但值得验证您是否有适当的部分,通过将其注释掉并再次运行基准测试。如果延迟显著下降(希望至少降低 50%),则您已经找到了需要关注的正确区域。例如,从深度卷积的参考实现中获取这段代码:

    for (int b = 0; b < batches; ++b) {
      for (int out_y = 0; out_y < output_height; ++out_y) {
        for (int out_x = 0; out_x < output_width; ++out_x) {
          for (int ic = 0; ic < input_depth; ++ic) {
            for (int m = 0; m < depth_multiplier; m++) {
              const int oc = m + ic * depth_multiplier;
              const int in_x_origin = (out_x * stride_width) - pad_width;
              const int in_y_origin = (out_y * stride_height) - pad_height;
              int32 acc = 0;
              for (int filter_y = 0; filter_y < filter_height; ++filter_y) {
                for (int filter_x = 0; filter_x < filter_width; ++filter_x) {
                  const int in_x =
                      in_x_origin + dilation_width_factor * filter_x;
                  const int in_y =
                      in_y_origin + dilation_height_factor * filter_y;
                  // If the location is outside the bounds of the input image,
                  // use zero as a default value.
                  if ((in_x >= 0) && (in_x < input_width) && (in_y >= 0) &&
                      (in_y < input_height)) {
                    int32 input_val =
                        input_data[Offset(input_shape, b, in_y, in_x, ic)];
                    int32 filter_val = filter_data[Offset(
                        filter_shape, 0, filter_y, filter_x, oc)];
                    acc += (filter_val + filter_offset) *
                           (input_val + input_offset);
                  }
                }
              }
              if (bias_data) {
                acc += bias_data[oc];
              }
              acc = DepthwiseConvRound<output_rounding>(acc, output_multiplier,
                                                        output_shift);
              acc += output_offset;
              acc = std::max(acc, output_activation_min);
              acc = std::min(acc, output_activation_max);
              output_data[Offset(output_shape, b, out_y, out_x, oc)] =
                  static_cast<uint8>(acc);
            }
          }
        }
      }
    }

仅通过检查缩进,就可以确定正确的内部循环如下所示:

                  const int in_x =
                      in_x_origin + dilation_width_factor * filter_x;
                  const int in_y =
                      in_y_origin + dilation_height_factor * filter_y;
                  // If the location is outside the bounds of the input image,
                  // use zero as a default value.
                  if ((in_x >= 0) && (in_x < input_width) && (in_y >= 0) &&
                      (in_y < input_height)) {
                    int32 input_val =
                        input_data[Offset(input_shape, b, in_y, in_x, ic)];
                    int32 filter_val = filter_data[Offset(
                        filter_shape, 0, filter_y, filter_x, oc)];
                    acc += (filter_val + filter_offset) *
                           (input_val + input_offset);
                  }

这段代码被执行的次数比函数中的其他行要多得多,这是因为它位于所有循环的中间位置,将其注释掉将确认它占用了大部分时间。如果你有逐行分析信息的幸运,这也可以帮助你找到确切的部分。

现在你已经找到了一个高影响区域,目标是尽可能将更多工作移到不太关键的部分。例如,在中间有一个if语句,这意味着在每次内部循环迭代时必须执行条件检查,但可以将这部分工作提升到代码的其他部分,以便在外部循环中更少频繁地执行检查。你可能还会注意到一些条件或计算对于你的特定模型和基准测试是不需要的。在语音唤醒词模型中,扩张因子始终为 1,因此涉及它们的乘法可以被跳过,节省更多工作。我们建议您在顶层进行这种参数特定的优化检查,并在参数不符合优化要求时退回到普通的参考实现。这可以加速已知模型,但确保如果您有不符合这些标准的操作,它们至少能正常工作。为了确保您不会意外破坏正确性,值得经常运行操作的单元测试,因为您正在进行更改。

本书的范围超出了覆盖所有优化数值处理代码的方式,但您可以查看portable_optimized文件夹中的内核,看看一些可能有用的技术。

利用硬件特性

到目前为止,我们只讨论了不特定于平台的可移植优化。这是因为重构代码以完全避免工作通常是产生重大影响的最简单方法。它还简化了更专门优化的焦点和范围。您可能会发现自己在像 Cortex-M 设备这样的平台上,具有SIMD 指令,这些指令通常对神经网络推断中占用大部分时间的重复计算非常有帮助。您可能会诱惑直接使用内部函数或者甚至汇编来重写内部循环,但要抵制!至少要查看供应商提供的库的文档,看看是否已经有适合的内容来实现算法的较大部分,因为那可能已经高度优化了(尽管可能会错过您可以应用的优化,了解您的操作参数)。如果可以的话,尝试调用现有函数来计算一些常见的东西,比如快速傅立叶变换,而不是编写自己的版本。

如果您已经完成了这些阶段,那么现在是时候尝试您平台的汇编级别了。我们推荐的方法是从逐行将代码替换为其在汇编中的机械等效物开始,一次替换一行,这样您可以在进行过程中验证正确性,而不必一开始就担心加速。在您转换了必要的代码之后,您可以尝试融合操作和其他技术来减少延迟。与更复杂的处理器相比,嵌入式系统的一个优势是它们的行为通常比较简单,没有深层指令流水线或缓存,因此更容易在纸上理解潜在的性能,并建立潜在的汇编级优化,而不会有太多意外副作用的风险。

加速器和协处理器

随着机器学习工作负载在嵌入式世界中变得更加重要,我们看到越来越多的系统出现,提供专门的硬件来加速或降低它们所需的功耗。然而,目前还没有明确的编程模型或标准 API,因此并不总是清楚如何将它们与软件框架集成。通过 TensorFlow Lite for Microcontrollers,我们希望支持与主处理器同步工作的硬件的直接集成,但异步组件超出了当前项目的范围。

我们所说的同步是指加速硬件与主 CPU 紧密耦合,共享内存空间,并且操作员实现可以快速调用加速器,并在结果返回之前阻塞。从程序员的角度来看,这种加速器更像是早期 x86 系统上存在的浮点协处理器,而不是另一种更像 GPU 的模型。我们专注于这种同步加速器的原因是它们似乎对我们的低能耗系统最有意义,避免异步协调可以使运行时更简单。

类似协处理器的加速器需要与系统架构中的 CPU 非常接近,才能以如此低的延迟响应。相反的模型是现代 GPU 所使用的模型,其中有一个完全独立的系统,具有自己的控制逻辑,位于总线的另一端。编程这些类型的处理器涉及 CPU 排队一长串命令,这些命令需要相对较长的时间来执行,并在批处理准备就绪后立即发送,但立即继续其他工作,不等待加速器完成。在这种模型中,CPU 和加速器之间的通信延迟是微不足道的,因为发送命令的频率很低,而且没有等待结果。加速器可以从这种方法中受益,因为一次看到很多命令会提供许多重新排列和优化工作的机会,这在任务更加细粒度且需要按顺序执行时很难做到。这对图形渲染非常适用,因为结果根本不需要返回给 CPU;渲染的显示缓冲区只需显示给用户。通过向深度学习训练发送大批量的训练样本,可以确保一次有很多工作要做,并尽可能多地保留在卡上,避免将数据复制回 CPU。随着嵌入式系统变得更加复杂并承担更大的工作负载,我们可能会重新审视框架的要求,并通过类似移动版 TensorFlow Lite 中的委托接口来支持这种流程,但这超出了我们当前版本库的范围。

回馈开源

我们始终热衷于看到对 TensorFlow Lite 的贡献,当您努力优化一些框架代码后,您可能会有兴趣将其分享回主线。一个很好的开始是加入SIG Micro邮件列表,并发送一封简短的电子邮件总结您所做的工作,以及指向带有您提议更改的 TensorFlow 存储库分支的指针。如果您包括您正在使用的基准测试以及一些内联文档讨论优化将有所帮助的地方,那将会很有帮助。社区应该能够提供反馈;他们将寻找可以在其基础上构建的东西,通常是有用的,并且可以维护和测试。我们迫不及待地想看看您的成果,感谢您考虑开源您的改进!

收尾

在本章中,我们介绍了加快模型执行速度所需了解的最重要的事情。最快的代码是根本不运行的代码,所以要记住的关键是在开始优化单个函数之前,在模型和算法级别缩小您正在进行的工作。您可能需要解决延迟问题,然后才能让您的应用程序在真实设备上运行,并测试它是否按照您的意图工作。之后,下一个优先事项可能是确保您的设备具有足够的寿命以便有用——这就是下一章关于优化能源使用的地方将会有用的地方。

第十六章:优化能量使用

嵌入式设备相对于台式机或移动系统最重要的优势是它们消耗的能量非常少。服务器 CPU 可能消耗几十甚至几百瓦,需要冷却系统和主电源供应才能运行。即使手机也可能消耗几瓦,并需要每天充电。微控制器可以以不到一毫瓦的功率运行,比手机 CPU 少一千倍以上,因此可以在硬币电池或能量收集上运行数周、数月或数年。

如果您正在开发 TinyML 产品,最具挑战性的限制可能是电池寿命。需要人为干预更换或充电电池通常是不可行的,因此您设备的有用寿命(它将继续工作多长时间)将由其使用的能量量和存储量来定义。电池容量通常受产品的物理尺寸限制(例如,一个剥离式传感器不太可能能够容纳超过一个硬币电池),即使您能够使用能量收集,对其供应的功率也有严格限制。这意味着您可以控制的主要领域是影响设备寿命的能量系统使用量。在本章中,我们将讨论如何调查您的功耗以及如何改进它。

培养直觉

大多数台式工程师对不同类型操作所需时间有一个大致的了解,他们知道网络请求可能比从 RAM 读取数据慢,通常更快地从固态硬盘(SSD)访问文件比从旋转磁盘驱动器访问文件快。但是很少有人需要考虑不同功能需要多少能量,但为了建立心理模型并计划功率效率,您需要一些经验法则来了解您的操作需要多少能量。

注意

在本章中,我们在能量和功率测量之间来回切换。功率是能量随时间的变化,因此例如,每秒使用 1 焦耳(J)能量的 CPU 将使用 1 瓦特的功率。由于我们最关心的是设备的寿命,因此通常最有帮助的是专注于平均功率使用量作为度量标准,因为这与设备在电池中存储的固定能量量上运行的时间长度成正比。这意味着我们可以轻松预测,一个平均功率使用量为 1 毫瓦的系统将持续时间是一个使用 2 毫瓦的系统的两倍。我们有时仍会提到一次性操作的能量使用,这些操作不会持续很长时间。

典型组件功率使用

如果您想深入了解系统组件使用多少能量,Sasu Tarkoma 等人的《智能手机能量消耗》(剑桥大学出版社)是一个很好的开始。以下是我们从他们的计算中得出的一些数字:

  • Arm Cortex-A9 CPU 的功耗在 500 到 2000 毫瓦之间。

  • 显示器可能使用 400 毫瓦。

  • 活动蜂窝无线电可能使用 800 毫瓦。

  • 蓝牙可能使用 100 毫瓦。

超越智能手机,以下是我们观察到的嵌入式组件的最佳测量值:

  • 一个麦克风传感器可能使用 300 微瓦(µW)。

  • 蓝牙低功耗可能使用 40 毫瓦。

  • 一个 320×320 像素的单色图像传感器(如 Himax HM01B0)可能在 30 FPS 时使用 1 毫瓦。

  • Ambiq Cortex-M4F 微控制器可能在 48 MHz 时钟频率下使用 1 毫瓦。

  • 一个加速度计可能使用 1 毫瓦。

这些数字将根据您使用的确切组件而有很大变化,但它们对于您至少了解不同操作的大致比例是有用的。一个顶层摘要是,无线电使用的功率比您在嵌入式产品中可能需要的其他功能要多得多。此外,传感器和处理器的能量需求下降速度比通信功率快得多,因此未来这种差距可能会进一步增加。

一旦您了解了系统中活动组件可能使用的能量,您需要考虑您可以存储或收集多少能量来为它们供电。以下是一些大致数字(感谢James Meyers提供的能量收集估算):

  • 一个 CR2032 纽扣电池可能容纳 2,500 焦耳。这意味着如果您的系统平均使用 1 毫瓦的功率,您可以希望获得大约一个月的使用时间。

  • 一个 AA 电池可能有 15,000 焦耳,为 1 毫瓦系统提供六个月的使用寿命。

  • 从工业机器中收集温差可能会产生每平方厘米 1 至 10 毫瓦的能量。

  • 室内光源可能每平方厘米提供 10 微瓦的能量。

  • 室外光照可能使您能够每平方厘米收集 10 毫瓦的能量。

正如您所看到的,目前只有工业温差或室外光照对于自供电设备是实际可行的,但随着处理器和传感器的能量需求降低,我们希望使用其他方法将开始变得可能。您可以关注商业供应商如Matrixe-peas以了解一些最新的能量收集设备。

希望这些大致数字能帮助您勾勒出对于您的寿命、成本和尺寸要求组合可能实用的系统类型。它们应该足够至少进行初步可行性检查,如果您能将它们内化为直觉,您将能够快速思考许多不同的潜在权衡。

硬件选择

当您大致了解您的产品可能使用的组件类型时,您需要查看您可以购买的实际零件。如果您正在寻找一些对爱好者来说文档完备且易于获取的东西,最好从浏览像SparkFunArduinoAdaFruit这样的网站开始。这些网站提供的组件配有教程、驱动程序和有关连接到其他部件的建议。它们也是开始原型设计的最佳地点,因为您很可能能够获得已经配置好您所需的一切的完整系统。最大的缺点是您的选择会更有限,集成系统可能不会针对整体功耗进行优化,而且您将为额外资源支付溢价。

为了更多选择和更低价格,但没有宝贵的支持,您可以尝试像Digi-KeyMouser Electronics或甚至Alibaba这样的电子供应商。所有这些网站的共同之处是它们应该为其所有产品提供数据表。这些数据表包含有关每个部件的丰富细节:从如何提供时钟信号到有关芯片大小及其引脚的机械数据。然而,您可能最想了解的第一件事是功耗,而这可能会令人惊讶地难以找到。例如,看看STMicroelectronics Cortex-M0 MCU 的数据表。这本书有近百页,从目录中一眼看去并不明显如何找到功耗。我们发现的一个有用技巧是在这些文档中搜索“毫安”或“ma”(带有空格),因为这些通常是用来表示功耗的单位。在这份数据表中,这种搜索导致了第 47 页上的一个表,如图 16-1 所示,提供了电流消耗的值。

VDD 供电时的典型和最大电流消耗

图 16-1. STMicroelectronics 的电流消耗表

这仍然可能很难解释,但我们通常感兴趣的是这个芯片可能使用多少瓦特(或毫瓦)。为了得到这个值,我们需要将表中显示的安培数乘以电压,这里列出的电压为 3.6 伏特(我们已经在表的顶部突出显示了这一点)。如果我们这样做,我们可以看到典型功耗范围从接近 100 毫瓦到只有在睡眠模式下的 10 毫瓦。这让我们知道这个微控制器在功耗方面相对较高,尽管其价格为 55 美分,可能会在您的权衡中得到补偿。您应该能够对您有兴趣使用的所有组件的数据表执行类似的侦探工作,并根据所有这些部分的总和来组装一个关于可能整体功耗的图像。

测量实际功耗

一旦您有了一组组件,您将需要将它们组装成一个完整的系统。这个过程超出了本书的范围,但我们建议您尽早完成一些工作,以便在实际世界中尝试产品并了解更多关于其需求的信息。即使您没有使用您想要的组件或者没有准备好所有软件,获得早期反馈也是非常宝贵的。

拥有一个完整的系统的另一个好处是您可以测试实际的功耗。数据表和估算对于规划是有帮助的,但总有一些东西无法适应简单模型,集成测试通常会显示比您预期的更高的功耗。

有很多工具可以用来测量系统的功耗,了解如何使用万用表(一种用于测量各种电气特性的设备)可能非常有帮助,但最可靠的方法是在设备中放置一个已知容量的电池,然后看它能持续多久。毕竟,这才是您真正关心的,尽管您可能希望它的寿命为几个月或几年,但最有可能的是,您的第一次尝试只能运行几个小时或几天。这种实验方法的优势在于它捕捉了您关心的所有效果,包括当电压下降太低时可能出现的故障,这可能不会在简单的建模计算中显示出来。这种方法也非常简单,即使是软件工程师也可以做到!

为模型估算功耗

估计模型在特定设备上使用多少功率的最简单方法是测量运行一个推理所需的延迟,然后将系统的平均功耗乘以该时间段的能量使用量。在项目开始阶段,你可能不太可能有延迟和功耗的确切数字,但你可以得出大致的数字。如果你知道一个模型需要多少算术运算,以及处理器每秒大约可以执行多少运算,你可以大致估计该模型执行所需的时间。数据表通常会给出设备在特定频率和电压下的功耗数据,尽管要注意的是它们可能不包括整个系统的常见部分,比如内存或外设。值得对这些早期估计持怀疑态度,并将它们用作你可能实现的上限,但至少你可以对你的方法的可行性有一些想法。

举个例子,如果你有一个像人体检测器一样需要执行 6000 万次操作的模型,而你有一个像 Arm Cortex-M4 这样以 48 MHz 运行的芯片,并且你相信它可以使用其 DSP 扩展每个周期执行两次 8 位乘加运算,你可能会猜测最大延迟为 48,000,000/60,000,000 = 800 毫秒。如果你的芯片使用 2 毫瓦,那么每次推理的能量消耗将为 1.6(毫焦)。

改进功耗

现在你知道了系统的大致寿命,你可能会寻找改进的方法。你可能会找到一些硬件修改的方法,比如关闭你不需要的模块或更换组件,但这些超出了本书的范围。幸运的是,有一些常见的技术不需要电气工程知识,但可以帮助很多。因为这些方法是以软件为重点的,它们假设微控制器本身占据了大部分功耗。如果你的设备中的传感器或其他组件是耗电量大的,你将需要进行硬件调查。

占空比

几乎所有嵌入式处理器都有能力将自己置于睡眠模式中,在这种模式下它们不执行任何计算,功耗很低,但能够在一段时间后或外部信号进入时唤醒。这意味着减少功耗的最简单方法之一是在推理调用之间插入睡眠,以便处理器在低功耗模式下花费更多时间。这在嵌入式世界中通常被称为占空比。你可能会担心这会排除连续传感器数据采集,但许多现代微控制器具有直接内存访问(DMA)功能,能够连续采样模拟数字转换器(ADC)并将结果存储在内存中,而无需主处理器的参与。

类似地,你可能能够降低处理器执行指令的频率,使其实际上运行得更慢,从而大幅减少其功耗。之前展示的数据表示例演示了随着时钟频率降低所需能量的减少。

占空比和频率降低提供的是通过计算来交换功耗的能力。这在实践中意味着,如果你能减少软件的延迟,你可以用更低的功耗预算来交换。即使你能够在规定的时间内运行,也要寻找优化延迟的方法,如果你想要减少功耗。

级联设计

机器学习相对于传统的过程式编程的一个重要优势是,它可以轻松地扩展或缩减所需的计算和存储资源量,而准确性通常会逐渐降低。手动编码的算法很难实现这一点,因为通常没有明显的参数可以调整以影响这些属性。这意味着您可以创建所谓的模型级联。传感器数据可以输入到一个计算要求很小的模型中,即使它不是特别准确,也可以调整它,使其在特定条件存在时有很高的触发概率(即使它也会产生很多误报)。如果结果表明刚刚发生了有趣的事情,相同的输入可以被输入到一个更复杂的模型中,以产生更准确的结果。这个过程可以在几个更多的阶段中重复。

这种方法的好处在于,虽然不准确但微小的模型可以适应非常节能的嵌入式设备,并且持续运行它不会消耗太多能量。当发现潜在事件时,可以唤醒一个更强大的系统并运行一个更大的模型,依此类推。因为更强大的系统仅在很短的时间内运行,它们的功耗不会超出预算。这就是手机上始终开启的语音接口是如何工作的。DSP 不断监视麦克风,一个模型在监听“Alexa”、“Siri”、“Hey Google”或类似的唤醒词。主 CPU 可以保持在睡眠模式,但当 DSP 认为可能听到正确的短语时,它会发出信号唤醒它。然后 CPU 可以运行一个更大更准确的模型来确认是否确实是正确的短语,并且如果是的话,可能将随后的语音发送到云中更强大的处理器。

这意味着嵌入式产品即使不能承载一个足够准确以便自行采取行动的模型,也可能实现其目标。如果您能够训练一个能够发现大多数真正阳性的网络,并且假阳性发生的频率足够低,您可能可以将剩余的工作转移到云端。无线电非常耗电,但如果您能够将其使用限制在罕见的情况和短时间内,它可能符合您的能源预算。

总结

对于我们许多人(包括您的作者在内),优化能源消耗是一个陌生的过程。幸运的是,我们在优化延迟方面涵盖的许多技能在这里也适用,只是要监控不同的指标。通常最好先专注于延迟优化,因为您通常需要验证您的产品是否能够提供您想要的短期用户体验,即使其寿命不足以在现实世界中有用。同样,通常在延迟和能源之后处理第十七章的主题,空间优化,是有意义的。在实践中,您可能会在所有不同的权衡之间来回迭代,以满足您的约束条件,但在其他方面相对稳定之后,尺寸通常是最容易处理的。

第十七章:优化模型和二进制大小

无论您选择哪种平台,闪存存储和 RAM 都可能非常有限。大多数嵌入式系统的闪存只读存储器少于 1 MB,许多只有几十 KB。内存也是如此:很少有超过 512 KB 的静态 RAM(SRAM)可用,而在低端设备上,这个数字可能只有几个 KB。好消息是,TensorFlow Lite for Microcontrollers 被设计为可以使用至少 20 KB 的闪存和 4 KB 的 SRAM,但您需要仔细设计您的应用程序并做出工程权衡以保持占用空间较小。本章介绍了一些方法,您可以使用这些方法来监控和控制内存和存储需求。

了解系统的限制

大多数嵌入式系统具有一种架构,其中程序和其他只读数据存储在闪存存储器中,仅在上传新可执行文件时才写入。通常还有可修改的内存可用,通常使用 SRAM 技术。这是用于较大 CPU 缓存的相同技术,它提供快速访问和低功耗,但尺寸有限。更先进的微控制器可以提供第二层可修改内存,使用更耗电但可扩展的技术,如动态 RAM(DRAM)。

您需要了解潜在平台提供的内容以及权衡。例如,具有大量二级 DRAM 的芯片可能因其灵活性而具有吸引力,但如果启用额外的内存超出了您的功耗预算,那可能不值得。如果您正在操作本书关注的 1 mW 及以下功率范围,通常不可能使用超出 SRAM 的任何东西,因为更大的内存方法将消耗太多能量。这意味着您需要考虑的两个关键指标是可用的闪存只读存储器量和可用的 SRAM 量。这些数字应列在您查看的任何芯片的描述中。希望您甚至不需要深入挖掘数据表“硬件选择”。

估算内存使用量

当您了解硬件选项时,您需要了解软件将需要的资源以及您可以做出的权衡来控制这些要求。

闪存使用量

通常,通过编译完整的可执行文件,然后查看生成图像的大小,您可以确定在闪存中需要多少空间。这可能会令人困惑,因为链接器生成的第一个工件通常是带有调试符号和部分信息的可执行文件的注释版本,格式类似于 ELF(我们在“测量代码大小”中更详细地讨论)。您要查看的文件是实际上刷入设备的文件,通常由objcopy等工具生成。用于估算所需闪存内存量的最简单方程式是以下因素之和:

操作系统大小

如果您使用任何类型的实时操作系统(RTOS),则需要在可执行文件中留出空间来保存其代码。这通常可以根据您使用的功能进行配置,并且估算占用空间的最简单方法是使用所需功能构建一个示例“hello world”程序。如果查看图像文件大小,这将为您提供 OS 程序代码有多大的基准。可能占用大量程序空间的典型模块包括 USB、WiFi、蓝牙和蜂窝无线电堆栈,因此请确保启用它们,如果您打算使用它们。

TensorFlow Lite for Microcontrollers 代码大小

机器学习框架需要空间来加载和执行神经网络模型的程序逻辑,包括运行核心算术的操作实现。本章后面我们将讨论如何配置框架以减小特定应用程序的大小,但首先只需编译一个标准单元测试(比如micro_speech测试),其中包括框架,并查看估计的结果图像大小。

模型数据大小

如果您还没有训练好的模型,可以通过计算其权重来估计它所需的闪存存储空间。例如,全连接层的权重数量等于其输入向量的大小乘以其输出向量的大小。对于卷积层,情况会更复杂一些;您需要将滤波框的宽度和高度乘以输入通道的数量,然后乘以滤波器的数量。您还需要为与每一层相关的任何偏置向量添加存储空间。这很快就会变得复杂,因此最简单的方法可能是在 TensorFlow 中创建一个候选模型,然后将其导出为 TensorFlow Lite 文件。该文件将直接映射到闪存中,因此其大小将为您提供占用多少空间的确切数字。您还可以查看Keras 的model.summary()方法列出的权重数量。

注意

我们在第四章中介绍了量化,并在第十五章中进一步讨论了它,但在模型大小的背景下进行一个快速的复习是值得的。在训练期间,权重通常以浮点值存储,每个占用 4 个字节的内存。由于空间对于移动和嵌入式设备来说是一个限制,TensorFlow Lite 支持将这些值压缩到一个字节中,这个过程称为量化。它通过跟踪存储在浮点数组中的最小值和最大值,然后将所有值线性转换为该范围内均匀间隔的 256 个值中最接近的一个。这些代码都存储在一个字节中,可以对它们进行算术运算而几乎不损失精度。

应用程序代码大小

您需要编写代码来访问传感器数据,对其进行预处理以准备神经网络,并响应结果。您可能还需要一些其他类型的用户界面和机器学习模块之外的业务逻辑。这可能很难估计,但您至少应该尝试了解是否需要任何外部库(例如用于快速傅立叶变换),并计算它们的代码空间需求。

RAM 使用量

确定所需的可修改内存量可能比理解存储需求更具挑战性,因为程序的 RAM 使用量会随着程序的生命周期而变化。类似于估计闪存需求的过程,您需要查看软件的不同层以估计整体使用要求:

操作系统大小

大多数RTOS(如 FreeRTOS)记录了它们不同配置选项所需的 RAM 量,您应该能够使用这些信息来规划所需的大小。您需要注意可能需要缓冲区的模块,特别是通信堆栈如 TCP/IP、WiFi 或蓝牙。这些将需要添加到任何核心操作系统要求中。

微控制器的 TensorFlow Lite RAM 大小

ML 框架的核心运行时不需要大量内存,并且其数据结构在 SRAM 中不应该需要超过几千字节的空间。这些分配为解释器使用的类的一部分,因此您的应用程序代码是将这些创建为全局或局部对象将决定它们是在堆栈上还是在一般内存中。我们通常建议将它们创建为全局或static对象,因为空间不足通常会导致链接时错误,而堆栈分配的局部变量可能会导致更难理解的运行时崩溃。

模型内存大小

当神经网络执行时,一个层的结果被馈送到后续操作中,因此必须保留一段时间。这些激活层的寿命因其在图中的位置而异,每个激活层所需的内存大小由层写出的数组的形状控制。这些变化意味着需要随时间计划以将所有这些临时缓冲区尽可能地放入内存的小区域中。目前,这是在解释器首次加载模型时完成的,因此如果竞技场不够大,您将在控制台上看到错误。如果您在错误消息中看到可用内存与所需内存之间的差异,并将竞技场增加该数量,您应该能够解决该错误。

应用程序内存大小

与程序大小一样,应用程序逻辑的内存使用在编写之前可能很难计算。但是,您可以对内存的更大使用者进行一些猜测,例如您将需要用于存储传入样本数据的缓冲区,或者库将需要用于预处理的内存区域。

不同问题上模型准确性和大小的大致数字

了解不同类型问题的当前技术水平将有助于您规划您的应用程序可能实现的目标。机器学习并非魔法,了解其局限性将有助于您在构建产品时做出明智的权衡。第十四章探讨了设计过程,是开始培养直觉的好地方,但您还需要考虑随着模型被迫适应严格资源限制时准确性如何下降。为了帮助您,这里有一些为嵌入式系统设计的架构示例。如果其中一个接近您需要做的事情,可能会帮助您设想在模型创建过程结束时可能实现的结果。显然,您的实际结果将在很大程度上取决于您的具体产品和环境,因此请将这些作为规划的指导,并不要依赖于能够实现完全相同的性能。

语音唤醒词模型

我们之前提到的使用 400,000 次算术运算的小型(18 KB)模型作为代码示例,能够在区分四类声音时达到 85%的一级准确性(参见“建立度量”)。这是训练评估指标,这意味着通过呈现一秒钟的片段并要求模型对其输入进行一次分类来获得结果。在实践中,您通常会在流式音频上使用模型,根据逐渐向前移动的一秒钟窗口重复预测结果,因此在实际应用中的实际准确性低于该数字可能表明的准确性。您通常应该将这种大小的音频模型视为更大处理级联中的第一阶段门卫,以便更复杂的模型可以容忍和处理其错误。

作为一个经验法则,您可能需要一个具有 300 到 400 KB 权重和数千万算术操作的模型,才能以足够可接受的准确性检测唤醒词,以在语音界面中使用。不幸的是,您还需要一个商业质量的数据集进行训练,因为目前仍然没有足够的开放标记语音数据库可用,但希望这种限制随着时间的推移会减轻。

加速度计预测性维护模型

有各种不同的预测性维护问题,但其中一个较简单的情况是检测电机轴承故障。这通常表现为加速度计数据中可以看到的明显震动模式。一个合理的模型来识别这些模式可能只需要几千个权重,使其大小不到 10 KB,并且数十万个算术操作。您可以期望使用这样的模型对这些事件进行分类的准确率超过 95%,并且您可以想象从那里增加模型的复杂性来处理更困难的问题(例如检测具有许多移动部件或自行移动的机器上的故障)。当然,参数和操作的数量也会相应增加。

人员存在检测

计算机视觉在嵌入式平台上并不是常见的任务,因此我们仍在探索哪些应用是有意义的。我们听到的一个常见请求是能够检测到附近有人时,唤醒用户界面或执行其他更耗电的处理,这是不可能一直运行的。我们试图在Visual Wake Word Challenge中正式捕捉这个问题的要求,结果显示,如果使用一个 250 KB 模型和大约 6000 万算术操作,您可以期望在一个小(96×96 像素)单色图像的二进制分类中获得大约 90%的准确性。这是使用缩减版 MobileNet v2 架构的基线(如本书中早期描述的),因此我们希望随着更多研究人员解决这一特殊需求集,准确性会提高,但它给出了您在微控制器内存占用中可能在视觉问题上表现如何的粗略估计。您可能会想知道这样一个小模型在流行的 ImageNet-1000 类别问题上会表现如何 - 很难说确切的原因是最终的全连接层对于一千个类别很快就会占用一百多千字节(参数数量是嵌入输入乘以类别计数),但对于大约 500 KB 的总大小,您可以期望在 top-one 准确性方面达到大约 50%。

模型选择

在优化模型和二进制大小方面,我们强烈建议从现有模型开始。正如我们在第十四章中讨论的那样,投资最有价值的领域是数据收集和改进,而不是调整架构,从已知模型开始将让您尽早专注于数据改进。嵌入式平台上的机器学习软件也仍处于早期阶段,因此使用现有模型增加了其操作在您关心的设备上得到支持和优化的机会。我们希望本书附带的代码示例将成为许多不同应用的良好起点 - 我们选择它们以涵盖尽可能多种不同类型的传感器输入,但如果它们不适合您的用例,您可能可以在线搜索一些替代方案。如果找不到适合的大小优化架构,您可以尝试在 TensorFlow 的训练环境中从头开始构建自己的架构,但正如第十三章和第十九章讨论的那样,成功将其移植到微控制器可能是一个复杂的过程。

减小可执行文件的大小

您的模型可能是微控制器应用程序中只读内存的最大消耗者之一,但您还必须考虑编译代码占用了多少空间。代码大小的限制是我们在针对嵌入式平台时不能只使用未经修改的 TensorFlow Lite 的原因:它将占用数百 KB 的闪存内存。TensorFlow Lite for Microcontrollers 可以缩减至至少 20 KB,但这可能需要您进行一些更改,以排除您的应用程序不需要的代码部分。

测量代码大小

在开始优化代码大小之前,您需要知道它有多大。在嵌入式平台上,这可能有点棘手,因为构建过程的输出通常是一个文件,其中包含调试和其他信息,这些信息不会传输到嵌入式设备上,因此不应计入总大小限制。在 Arm 和其他现代工具链中,这通常被称为可执行和链接格式(ELF)文件,无论是否具有*.elf*后缀。如果您在 Linux 或 macOS 开发机器上,可以运行file命令来调查您的工具链的输出;它将向您显示文件是否为 ELF。

查看的更好文件通常被称为bin:实际上传到嵌入式设备的闪存存储的代码二进制快照。这通常会完全等于将要使用的只读闪存内存的大小,因此您可以使用它来了解实际使用情况。您可以通过在主机上使用ls -ldir之类的命令行,甚至在 GUI 文件查看器中检查它来找出其大小。并非所有工具链都会自动显示这个bin文件,它可能没有任何后缀,但它是您通过 USB 在 Mbed 上下载并拖放到设备上的文件,并且使用 gcc 工具链可以通过运行类似arm-none-eabi-objcopy app.elf app.bin -O binary来生成它。查看*.o中间文件或甚至构建过程生成的.a*库并不有用,因为它们包含了许多元数据,这些元数据不会出现在最终代码占用空间中,并且很多代码可能会被修剪为未使用。

因为我们期望您将模型编译为可执行文件中的 C 数据数组(因为您不能依赖存在文件系统来加载它),所以包括模型的任何程序的二进制大小将包含模型数据。要了解实际代码占用了多少空间,您需要从二进制文件长度中减去这个模型大小。模型大小通常应在包含 C 数据数组的文件中定义(比如在tiny_conv_micro_features_model_data.cc的末尾),因此您可以从二进制文件大小中减去它以了解真实的代码占用空间。

Tensorflow Lite for Microcontrollers 占用了多少空间?

当您了解整个应用程序的代码占用空间大小时,您可能想要调查 TensorFlow Lite 占用了多少空间。测试这一点的最简单方法是注释掉所有对框架的调用(包括创建OpResolvers和解释器等对象),看看二进制文件变小了多少。您应该至少期望减少 20 到 30 KB,因此如果您没有看到类似的情况,您应该再次检查是否捕捉到了所有引用。这应该有效,因为链接器将剥离您从未调用的任何代码,将其从占用空间中删除。这也可以扩展到代码的其他模块,只要确保没有引用,以帮助更好地了解空间的去向。

OpResolver

TensorFlow Lite 支持 100 多种操作,但在单个模型中不太可能需要所有这些操作。每个操作的单独实现可能只占用几千字节,但随着这么多可用的操作,总量很快就会增加。幸运的是,有一种内置机制可以去除你不需要的操作的代码占用空间。

当 TensorFlow Lite 加载模型时,它会使用OpResolver接口来搜索每个包含的操作的实现。这是一个你传递给解释器以加载模型的类,它包含了查找函数指针以获取操作实现的逻辑,给定操作定义。存在这个的原因是为了让你可以控制哪些实现实际上被链接进来。在大多数示例代码中,你会看到我们正在创建并传递一个AllOpsResolver类的实例。正如我们在第五章中讨论的那样,这实现了OpResolver接口,正如其名称所示,它为 TensorFlow Lite for Microcontrollers 中支持的每个操作都有一个条目。这对于入门很方便,因为这意味着你可以加载任何支持的模型,而不必担心它包含哪些操作。

然而,当你开始担心代码大小时,你会想要重新审视这个类。在你的应用程序主循环中,不要再传递AllOpsResolver的实例,而是将all_ops_resolver.cc和*.h文件复制到你的应用程序中,并将它们重命名为my_app_resolver.cc.h*,类重命名为MyAppResolver。在你的类构造函数中,删除所有适用于你模型中不使用的操作的AddBuiltin()调用。不幸的是,我们不知道有一种简单的自动方式来创建模型使用的操作列表,但Netron模型查看器是一个可以帮助这个过程的好工具。

确保你用MyAppResolver替换你传递给解释器的AllOpsResolver实例。现在,一旦编译你的应用程序,你应该会看到大小明显缩小。这个改变背后的原因是,大多数链接器会自动尝试删除不能被调用的代码(或死代码)。通过删除AllOpsResolver中的引用,你允许链接器确定可以排除所有不再列出的操作实现。

如果你只使用了少数操作,你不需要像我们使用大型AllOpsResolver那样将注册包装在一个新类中。相反,你可以创建一个MicroMutableOpResolver类的实例,并直接添加你需要的操作注册。MicroMutableOpResolver实现了OpResolver接口,但有额外的方法让你添加操作到列表中(这就是为什么它被命名为Mutable)。这是用来实现AllOpsResolver的类,也是你自己的解析器类的一个很好的基础,但直接调用它可能更简单。我们在一些示例中使用了这种方法,你可以在这个来自micro_speech示例的片段中看到它是如何工作的:

  static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
  micro_mutable_op_resolver.AddBuiltin(
      tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
      tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
  micro_mutable_op_resolver.AddBuiltin(
      tflite::BuiltinOperator_FULLY_CONNECTED,
      tflite::ops::micro::Register_FULLY_CONNECTED());
  micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
                                       tflite::ops::micro::Register_SOFTMAX());

你可能会注意到我们将解析器对象声明为static。这是因为解释器可以随时调用它,所以它的生命周期至少需要与我们为解释器创建的对象一样长。

理解单个函数的大小

如果你使用 GCC 工具链,你可以使用像nm这样的工具来获取目标(.o)中间文件中函数和对象的大小信息。这里有一个构建二进制文件然后检查编译后的audio_provider.cc对象文件中项目大小的示例:

nm -S tensorflow/lite/micro/tools/make/gen/ \
  sparkfun_edge_cortex-m4/obj/tensorflow/lite/micro/ \
  examples/micro_speech/sparkfun_edge/audio_provider.o

你应该会看到类似以下的结果:

00000140 t $d
00000258 t $d
00000088 t $d
00000008 t $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 b $d
00000000 r $d
00000000 r $d
00000000 t $t
00000000 t $t
00000000 t $t
00000000 t $t
00000001 00000178 T am_adc_isr
         U am_hal_adc_configure
         U am_hal_adc_configure_dma
         U am_hal_adc_configure_slot
         U am_hal_adc_enable
         U am_hal_adc_initialize
         U am_hal_adc_interrupt_clear
         U am_hal_adc_interrupt_enable
         U am_hal_adc_interrupt_status
         U am_hal_adc_power_control
         U am_hal_adc_sw_trigger
         U am_hal_burst_mode_enable
         U am_hal_burst_mode_initialize
         U am_hal_cachectrl_config
         U am_hal_cachectrl_defaults
         U am_hal_cachectrl_enable
         U am_hal_clkgen_control
         U am_hal_ctimer_adc_trigger_enable
         U am_hal_ctimer_config_single
         U am_hal_ctimer_int_enable
         U am_hal_ctimer_period_set
         U am_hal_ctimer_start
         U am_hal_gpio_pinconfig
         U am_hal_interrupt_master_enable
         U g_AM_HAL_GPIO_OUTPUT_12
00000001 0000009c T _Z15GetAudioSamplesPN6tflite13ErrorReporterEiiPiPPs
00000001 000002c4 T _Z18InitAudioRecordingPN6tflite13ErrorReporterE
00000001 0000000c T _Z20LatestAudioTimestampv
00000000 00000001 b _ZN12_GLOBAL__N_115g_adc_dma_errorE
00000000 00000400 b _ZN12_GLOBAL__N_121g_audio_output_bufferE
00000000 00007d00 b _ZN12_GLOBAL__N_122g_audio_capture_bufferE
00000000 00000001 b _ZN12_GLOBAL__N_122g_is_audio_initializedE
00000000 00002000 b _ZN12_GLOBAL__N_122g_ui32ADCSampleBuffer0E
00000000 00002000 b _ZN12_GLOBAL__N_122g_ui32ADCSampleBuffer1E
00000000 00000004 b _ZN12_GLOBAL__N_123g_dma_destination_indexE
00000000 00000004 b _ZN12_GLOBAL__N_124g_adc_dma_error_reporterE
00000000 00000004 b _ZN12_GLOBAL__N_124g_latest_audio_timestampE
00000000 00000008 b _ZN12_GLOBAL__N_124g_total_samples_capturedE
00000000 00000004 b _ZN12_GLOBAL__N_128g_audio_capture_buffer_startE
00000000 00000004 b _ZN12_GLOBAL__N_1L12g_adc_handleE
         U _ZN6tflite13ErrorReporter6ReportEPKcz

许多这些符号是内部细节或无关紧要的,但最后几个可以识别为我们在audio_provider.cc中定义的函数,它们的名称被搅乱以匹配 C++链接器约定。第二列显示它们的大小是多少十六进制。您可以看到InitAudioRecording()函数的大小为0x2c4或 708 字节,这在小型微控制器上可能相当显著,因此如果空间紧张,值得调查函数内部大小的来源。

我们发现的最佳方法是将源代码与反汇编函数混合在一起。幸运的是,objdump工具通过使用-S标志让我们可以做到这一点——但与nm不同,您不能使用安装在 Linux 或 macOS 桌面上的标准版本。相反,您需要使用随您的工具链一起提供的版本。如果您正在使用 TensorFlow Lite for Microcontrollers 的 Makefile 构建,通常会自动下载。它通常会存在于类似tensorflow/lite/micro/tools/make/downloads/gcc_embedded/bin的位置。以下是一个运行以查看audio_provider.cc内部函数更多信息的命令:

tensorflow/lite/micro/tools/make/downloads/gcc_embedded/bin/ \
  arm-none-eabi-objdump -S tensorflow/lite/micro/tools/make/gen/ \
  sparkfun_edge_cortex-m4/obj/tensorflow/lite/micro/examples/ \
  micro_speech/sparkfun_edge/audio_provider.o

我们不会展示所有的输出,因为太长了;相反,我们只展示一个简化版本,只显示我们感兴趣的函数:

...
Disassembly of section .text._Z18InitAudioRecordingPN6tflite13ErrorReporterE:

00000000 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE>:

TfLiteStatus InitAudioRecording(tflite::ErrorReporter* error_reporter) {
   0:	b570      	push	{r4, r5, r6, lr}
  // Set the clock frequency.
  if (AM_HAL_STATUS_SUCCESS !=
      am_hal_clkgen_control(AM_HAL_CLKGEN_CONTROL_SYSCLK_MAX, 0)) {
   2:	2100      	movs	r1, #0
TfLiteStatus InitAudioRecording(tflite::ErrorReporter* error_reporter) {
   4:	b088      	sub	sp, #32
   6:	4604      	mov	r4, r0
      am_hal_clkgen_control(AM_HAL_CLKGEN_CONTROL_SYSCLK_MAX, 0)) {
   8:	4608      	mov	r0, r1
   a:	f7ff fffe 	bl	0 <am_hal_clkgen_control>
  if (AM_HAL_STATUS_SUCCESS !=
   e:	2800      	cmp	r0, #0
  10:	f040 80e1 	bne.w	1d6 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE+0x1d6>
    return kTfLiteError;
  }

  // Set the default cache configuration and enable it.
  if (AM_HAL_STATUS_SUCCESS !=
      am_hal_cachectrl_config(&am_hal_cachectrl_defaults)) {
  14:	4890      	ldr	r0, [pc, #576]	; (244 <am_hal_cachectrl_config+0x244>)
  16:	f7ff fffe 	bl	0 <am_hal_cachectrl_config>
  if (AM_HAL_STATUS_SUCCESS !=
  1a:	2800      	cmp	r0, #0
  1c:	f040 80d4 	bne.w	1c8 <_Z18InitAudioRecordingPN6tflite13ErrorReporterE+0x1c8>
    error_reporter->Report("Error - configuring the system cache failed.");
    return kTfLiteError;
  }
  if (AM_HAL_STATUS_SUCCESS != am_hal_cachectrl_enable()) {
  20:	f7ff fffe 	bl	0 <am_hal_cachectrl_enable>
  24:	2800      	cmp	r0, #0
  26:	f040 80dd 	bne.w	1e4 <_Z18InitAudioRecordingPN6tflite13Error\
    ReporterE+0x1e4>
...

您不需要理解汇编在做什么,但希望您可以看到通过查看函数大小(反汇编行最左边的数字;例如,在InitAudioRecording()末尾的十六进制10)如何随着每个 C++源代码行的增加而增加。如果查看整个函数,您会发现所有的硬件初始化代码都已内联在InitAudioRecording()实现中,这解释了为什么它如此庞大。

框架常量

在库代码中有一些地方我们使用硬编码的数组大小来避免动态内存分配。如果 RAM 空间非常紧张,值得尝试看看是否可以减少它们以适应您的应用程序(或者,对于非常复杂的用例,甚至可能需要增加它们)。其中一个数组是TFLITE_REGISTRATIONS_MAX,它控制可以注册多少不同的操作。默认值为 128,这对于大多数应用程序来说可能太多了——特别是考虑到它创建了一个包含 128 个TfLiteRegistration结构的数组,每个结构至少占用 32 字节,需要 4 KB 的 RAM。您还可以查看像MicroInterpreter中的kStackDataAllocatorSize这样的较小的问题,或者尝试缩小您传递给解释器构造函数的 arena 的大小。

真正微小的模型

本章中的许多建议都与能够承受使用 20 KB 框架代码占用的嵌入式系统有关,以运行机器学习,并且不试图仅使用不到 10 KB 的 RAM。如果您的设备资源约束非常严格——例如,只有几千字节的 RAM 或闪存,您将无法使用相同的方法。对于这些环境,您需要编写自定义代码,并非常小心地调整每个细节以减小大小。

我们希望 TensorFlow Lite for Microcontrollers 在这些情况下仍然有用。我们建议您仍然在 TensorFlow 中训练一个模型,即使它很小,然后使用导出工作流从中创建一个 TensorFlow Lite 模型文件。这可以作为提取权重的良好起点,并且您可以使用现有的框架代码来验证您自定义版本的结果。您正在使用的操作的参考实现也应该是您自己操作代码的良好起点;它们应该是可移植的、易于理解的,并且在内存效率方面表现良好,即使它们对延迟不是最佳的。

总结

在这一章中,我们看了一些最好的技术,来缩小嵌入式机器学习项目所需的存储量。这很可能是你需要克服的最艰难的限制之一,但当你拥有一个足够小、足够快、并且不消耗太多能量的应用程序时,你就有了一个明确的路径来推出你的产品。剩下的是排除所有不可避免的小精灵,它们会导致你的设备以意想不到的方式行为。调试可能是一个令人沮丧的过程(我们听说过它被描述为一场谋杀案,你是侦探、受害者和凶手),但这是一个必须学会的技能,以便将产品推向市场。第十八章介绍了可以帮助你理解机器学习系统发生了什么的基本技术。

第十八章:调试

当您将机器学习集成到您的产品中时,无论是嵌入式还是其他方式,您都很可能会遇到一些令人困惑的错误,而且可能会比您想象的要早。在本章中,我们将讨论一些在事情出错时理解发生了什么的方法。

训练和部署之间的准确性损失

当您将一个机器学习模型从 TensorFlow 等创作环境部署到应用程序中时,问题可能会悄然而至。即使您能够构建和运行模型而不报告任何错误,您可能仍然无法获得您期望的准确性结果。这可能会非常令人沮丧,因为神经网络推断步骤似乎是一个黑匣子,没有内部发生的可见性或导致任何问题的原因。

预处理差异

在机器学习研究中很少受到关注的一个领域是如何将训练样本转换为神经网络可以操作的形式。如果您尝试对图像进行对象分类,那么这些图像必须转换为张量,即数字的多维数组。您可能会认为这应该很简单,因为图像已经以 2D 数组的形式存储,通常具有红色、绿色和蓝色值的三个通道。即使在这种情况下,您仍然需要进行一些更改。分类模型期望它们的输入具有特定的宽度和高度,例如宽 224 像素,高 224 像素,而相机或其他输入源不太可能以正确的尺寸产生它们。这意味着您需要将捕获的数据重新缩放以匹配。对于训练过程也必须做类似的处理,因为数据集可能是磁盘上一组任意大小的图像。

一个经常出现的微妙问题是,用于部署的重新缩放方法与用于训练模型的方法不匹配。例如,早期版本的Inception使用双线性缩放来缩小图像,这让具有图像处理背景的人感到困惑,因为这种方式的缩小会降低图像的视觉质量,通常应该避免。因此,许多开发人员在应用程序中使用这些模型进行推断时,改用了更正确的区域采样方法,但事实证明,这实际上降低了结果的准确性!直觉是,训练模型已经学会寻找双线性缩放产生的伪影,而它们的缺失导致了前一错误率增加了几个百分点。

图像预处理并不仅止于重新缩放步骤。还有一个问题,即如何将通常编码为 0 到 255 的图像值转换为训练期间使用的浮点数。出于几个原因,这些值通常会线性缩放到一个较小的范围内:要么是-1.0 到 1.0,要么是 0.0 到 1.0。如果您要输入浮点值,您需要在应用程序中进行相同的值缩放。如果您直接输入 8 位值,您在运行时不需要执行此操作——原始的 8 位值可以不经转换地使用,但您仍需要通过toco导出工具通过--mean_values--std_values标志将它们传递进去。对于-1.0 到 1.0 的范围,您可以使用--mean_values=128 --std_values=128

令人困惑的是,从模型代码中往往不明显知道输入图像值的正确比例应该是多少,因为这通常是隐藏在所使用 API 的实现中的细节。许多发布的 Google 模型使用的 Slim 框架默认为-1.0 到 1.0,因此这是一个不错的尝试范围,但如果没有记录,您可能最终不得不通过训练 Python 实现进行调试以找出其他情况下的正确比例。

更糟糕的是,即使调整大小或值缩放有点错误,您也可能得到大部分正确的结果,但会降低准确性。这意味着您的应用程序在初步检查时可能看起来正常运行,但最终体验可能不如预期那样令人印象深刻。图像预处理周围的挑战实际上比其他领域(如音频或加速度计数据)要简单得多,因为可能存在将原始数据转换为神经网络数字数组的复杂特征生成管道。如果查看micro_speech示例的预处理代码,您将看到我们必须实现许多信号处理阶段,以从音频样本获得可馈送到模型中的频谱图,任何此代码与训练中使用的版本之间的差异都会降低结果的准确性。

调试预处理

鉴于这些输入数据转换很容易出错,您可能甚至很难发现问题,即使发现了问题,也可能很难找出原因。您应该怎么办?我们发现有一些方法可以帮助。

如果可能的话,最好有一个可以在桌面机器上运行的代码版本,即使外围设备被存根。在 Linux、macOS 或 Windows 环境中,您将拥有更好的调试工具,并且可以轻松在训练工具和应用程序之间传输测试数据。对于 TensorFlow Lite for Microcontrollers 中的示例代码,我们将应用程序的不同部分拆分为模块,并为 Linux 和 macOS 目标启用了 Makefile 构建,因此我们可以分别运行推断和预处理阶段。

调试预处理问题最重要的工具是比较训练环境和应用程序中所看到的结果。最困难的部分是在训练过程中提取您关心的节点的正确值并控制输入是什么。本书的范围无法详细介绍如何做到这一点,但您需要识别与核心神经网络阶段对应的操作的名称(在文件解码、预处理和接收预处理结果的第一个操作之后)。接收预处理结果的第一个操作对应于toco--input_arrays参数。如果您能识别这些操作,请在 Python 中在每个操作后插入一个tf.print操作,其中summarize设置为-1。然后,如果运行训练循环,您将能够在调试控制台中看到每个阶段张量内容的打印输出。

然后,您应该能够将这些张量内容转换为 C 数据数组,然后将其编译到您的程序中。在micro_speech代码中有一些示例,比如一个说“yes”的一秒音频样本,以及预处理该输入的预期结果。在获得这些参考值之后,您应该能够将它们作为输入馈送到保存每个阶段的模块(预处理、神经网络推断)中,并确保输出与您的预期相匹配。如果时间不足,您可以使用临时代码来完成此操作,但将其转换为单元测试是值得额外投资的,以确保随着代码随时间变化,您的预处理和模型推断仍然得到验证。

设备上的评估

在训练结束时,神经网络会使用一组测试输入进行评估,将预测结果与期望结果进行比较,以表征模型的整体准确性。这是训练过程的正常部分,但很少对已部署在设备上的代码进行相同的评估。通常最大的障碍只是将构成典型测试数据集的成千上万个输入样本传输到资源有限的嵌入式系统上。然而,这是一种遗憾;确保设备上的准确性与训练结束时看到的准确性相匹配是确保模型已正确部署的唯一方法,因为有很多方式可以引入难以察觉的细微错误。我们没有设法为micro_speech演示实现完整的测试集评估,但至少有端到端测试,确保我们对两个不同输入获得正确的标签。

数值差异

神经网络是对大量数字数组执行的一系列复杂数学操作。原始训练通常是以浮点数进行的,但我们尝试将其转换为嵌入式应用程序的低精度整数表示。这些操作本身可以以许多不同的方式实现,取决于平台和优化权衡。所有这些因素意味着您不能期望从不同设备上的网络获得位级相同的结果,即使给定相同的输入。这意味着您必须确定您可以容忍的差异,并且如果这些差异变得太大,如何追踪其来源。

差异是否是问题?

我们有时开玩笑说,唯一真正重要的度量标准是应用商店评分。我们的目标应该是生产让人们满意的产品,因此所有其他度量标准只是用户满意度的代理。由于训练环境总会存在数值差异,第一个挑战是了解它们是否影响产品体验。如果您从网络中获得的值毫无意义,这可能很明显,但如果它们与预期值仅有几个百分点的差异,值得尝试将生成的网络作为具有现实用例的完整应用程序的一部分。也许准确性损失不是问题,或者有其他更重要的问题应该优先考虑。

建立一个度量标准

当您确定确实存在问题时,量化问题会有所帮助。可能会诱人选择一个数值度量,比如输出得分向量与期望结果之间的百分差异。然而,这可能并不很好地反映用户体验。例如,如果您正在进行图像分类,所有得分都比您期望的低 5%,但结果的相对排序保持不变,那么最终结果对于许多应用程序可能是完全合适的。

相反,我们建议设计一个反映产品需求的度量标准。在图像分类案例中,您可能会选择所谓的top-one分数,跨一组测试图像,因为这将显示模型选择正确标签的频率。top-one 度量标准是模型将地面真实标签选为最高得分预测的频率(top-five类似,但涵盖地面真实标签在五个最高得分预测中的频率)。然后,您可以使用 top-one 度量标准来跟踪您的进展,并且重要的是,了解您所做的更改何时足够好。

您还应小心组装一组标准输入,以反映实际输入到神经网络处理的内容,因为正如我们之前讨论的,预处理可能会引入错误。

与基准比较

TensorFlow Lite for Microcontrollers 被设计为具有其所有功能的参考实现,我们这样做的原因之一是为了能够将它们的结果与优化代码进行比较,以调试潜在的差异。一旦您有了一些标准输入,您应该尝试通过桌面版本的框架运行它们,不启用任何优化,以便调用参考操作符实现。如果您想要这种独立测试的起点,请查看micro_speech_test.cc。如果您将结果通过您建立的度量标准运行,您应该会看到您期望的分数。如果没有,可能在转换过程中出现了一些错误,或者在您的工作流程中的早期阶段出现了其他问题,因此您需要调试回到训练阶段以了解问题所在。

如果您看到使用参考代码获得了良好的结果,那么您应该尝试在目标平台上启用所有优化构建并运行相同的测试。当然,这可能并不像这么简单,因为通常嵌入式设备没有足够的内存来保存所有输入数据,如果您只有调试日志连接,输出结果可能会很棘手。然而,值得坚持,即使您必须将测试分成多次运行。当您获得结果时,请通过您的度量标准运行它们,以了解实际的差距是什么。

替换实现

许多平台默认启用优化,因为参考实现在嵌入式设备上运行时间太长,实际上无法使用。有很多方法可以禁用这些优化,但我们发现最简单的方法通常是找到当前正在使用的所有内核实现,通常在tensorflow/lite/micro/kernels的子文件夹中,并用该父目录中的参考版本覆盖它们(确保您备份要替换的文件)。作为第一步,替换所有优化实现并重新运行设备上的测试,以确保您看到您期望的更好分数。

在进行全面替换之后,尝试仅覆盖一半的优化内核,看看这如何影响度量标准。在大多数情况下,您可以使用二分搜索方法确定哪个优化内核实现导致分数最大下降。一旦您将其缩小到特定的优化内核,然后应该能够通过捕获运行之一的输入值和来自参考实现的这些输入的预期输出值来创建一个最小可重现案例。在测试运行期间从内核实现中进行调试日志记录是最简单的方法。

现在您有了一个可重现的案例,您应该能够从中创建一个单元测试。您可以查看标准内核测试之一以开始,并创建一个新的独立测试,或将其添加到该内核的现有文件中。这样,您就可以使用这个工具将问题传达给负责优化实现的团队,因为您将能够展示他们的代码和参考版本之间存在差异,并且这影响了您的应用程序。如果您将其贡献回去,同样的测试也可以添加到主代码库中,并确保没有其他优化实现会导致相同的问题。这也是一个很好的用于自行调试实现的工具,因为您可以在隔离的代码中进行实验并快速迭代。

神秘的崩溃和卡顿

在嵌入式系统中最难修复的情况之一是当你的程序无法运行,但没有明显的日志输出或错误来解释出了什么问题。理解问题的最简单方法是连接调试器(如 GDB),然后查看堆栈跟踪(如果挂起)或逐步执行代码,看看执行出了问题的地方。然而,设置调试器并不总是容易的,或者即使使用调试器后问题的根源仍然不明确,所以还有一些其他技术可以尝试。

桌面调试

像 Linux、macOS 和 Windows 这样的完整操作系统都有广泛的调试工具和错误报告机制,所以如果可能的话,尽量保持你的程序可以在这些平台之一上运行,即使你需要用虚拟实现替换一些硬件特定功能。这就是 TensorFlow Lite for Microcontrollers 的设计方式,这意味着我们可以首先尝试在我们的 Linux 机器上重现任何出现问题的情况。如果在这个环境中发生了相同的错误,通常使用标准工具进行跟踪会更容易更快速,而且无需刷写设备,加快迭代速度。即使维护整个应用程序作为桌面构建太困难,至少看看是否可以为你的模块创建可以在桌面上编译的单元测试和集成测试。然后你可以尝试给它们提供与你遇到问题的情况类似的输入,看看是否也会导致类似的错误。

日志追踪

TensorFlow Lite for Microcontrollers 唯一需要的平台特定功能是DebugLog()的实现。我们有这个要求是因为在开发过程中理解发生了什么是如此重要,即使在生产部署中并不需要。在理想的情况下,任何崩溃或程序错误都应该触发日志输出,例如,我们为 STM32 设备提供的裸机支持有一个故障处理程序来实现这一点,但这并不总是可行的。

你应该始终能够自己向代码中注入日志语句。这些语句不需要有意义,只需要说明代码中的位置。你甚至可以定义一个自动跟踪宏,就像这样:

#define TRACE DebugLog(__FILE__ ":" __LINE__)

然后在你的代码中像这样使用它:

int main(int argc, char**argv) {
  TRACE;
  InitSomething();
  TRACE;
  while (true) {
    TRACE;
    DoSomething();
    TRACE;
  }
}

你应该在调试控制台中看到输出,显示代码执行到了哪个位置。通常最好从代码的最高级别开始,然后看看日志停在哪里。这将让你大致了解崩溃或挂起发生的区域,然后你可以添加更多的TRACE语句来进一步确定问题发生的具体位置。

散弹式调试

有时候追踪并不能提供足够的信息来解释出现问题的原因,或者问题可能只会在你无法访问日志的环境中发生,比如生产环境。在这种情况下,我们建议使用所谓的“散弹式调试”。这类似于我们在第十五章中介绍的“散弹式性能分析”,只需要注释掉代码的部分部分,看看错误是否仍然发生。如果你从应用程序的顶层开始,逐步向下工作,通常可以做到类似于二分查找的方式来确定哪些代码行导致了问题。例如,你可以从主循环中的某些内容开始:

int main(int argc, char**argv) {
  InitSomething();
  while (true) {
    // DoSomething();
  }
}

如果使用注释掉DoSomething()成功运行,那么你就知道问题发生在该函数内部。然后你可以取消注释,并递归地在其内部执行相同的操作,以便集中关注出现问题的代码。

内存损坏

最痛苦的错误是由于内存中的值被意外覆盖而引起的。嵌入式系统没有与台式机或移动 CPU 相同的硬件来防止这种情况,因此这些问题可能特别难以调试。即使跟踪或注释掉代码也可能产生令人困惑的结果,因为覆盖可能发生在使用损坏值的代码运行之前很久,因此崩溃可能与其原因相距甚远。它们甚至可能依赖于传感器输入或硬件定时,使问题变得间歇性且难以复现。

我们的经验中,导致这种情况的头号原因是超出程序堆栈。这是存储本地变量的地方,而 TensorFlow Lite for Microcontrollers 广泛使用这些变量来存储相对较大的对象;因此,它需要比许多其他嵌入式应用程序更多的空间。不幸的是,确切的所需大小并不容易确定。通常,最大的贡献者是您需要传递给SimpleTensorAllocator的内存区域,该区域在示例中被分配为本地数组:

  // Create an area of memory to use for input, output, and intermediate arrays.
  // The size of this will depend on the model you're using, and may need to be
  // determined by experimentation.
  const int tensor_arena_size = 10 * 1024;
  uint8_t tensor_arena[tensor_arena_size];
  tflite::SimpleTensorAllocator tensor_allocator(tensor_arena,
                                                 tensor_arena_size);

如果您使用相同的方法,您需要确保堆栈大小大约等于该区域的大小,再加上运行时使用的几千字节的杂项变量。如果您的区域存放在其他地方(可能作为全局变量),则您只需要几千字节的堆栈。所需的确切内存量取决于您的架构、编译器和正在运行的模型,因此不幸的是,事先很难给出确切的值。如果您遇到神秘的崩溃,值得尽可能增加此值,以查看是否有所帮助。

如果您仍然遇到问题,您应该首先尝试确定哪个变量或内存区域被覆盖。希望可以使用之前描述的日志记录或代码消除方法来实现这一点,将问题缩小到似乎已被损坏的值的读取。一旦您知道哪个变量或数组条目被破坏,您可以编写一个类似于TRACE宏的变体,该宏输出该内存位置的值以及调用它的文件和行。您可能需要执行特殊技巧,例如将内存地址存储在全局变量中,以便在本地时可以从更深的堆栈帧中访问。然后,就像您追踪普通崩溃一样,您可以在运行程序并尝试确定哪些代码负责覆盖它时,TRACE出该位置的内容。

总结

在训练环境中正常工作但在实际设备上失败时提出解决方案可能是一个漫长而令人沮丧的过程。在本章中,我们为您提供了一套工具,当您发现自己陷入困境并一筹莫展时,可以尝试使用这些方法。不幸的是,在调试中没有太多捷径,但通过使用这些方法系统地解决问题,我们确信您可以追踪到任何嵌入式机器学习问题。

一旦您在产品中使一个模型正常工作,您可能会开始思考如何调整它,甚至创建一个全新的模型来解决不同的问题。第十九章讨论了如何将您自己的模型从 TensorFlow 训练环境转移到 TensorFlow Lite 推断引擎中。

第十九章:将模型从 TensorFlow 迁移到 TensorFlow Lite

如果你已经走到这一步,你会明白我们倡导在新任务中尽可能重用现有模型。从头开始训练一个全新的模型可能需要大量时间和实验,即使是专家也经常无法在尝试许多不同的原型之前预测最佳方法。这意味着创建新架构的完整指南超出了本书的范围,我们建议查看第二十一章以获取更多相关信息。然而,有一些方面(如使用受限操作集或预处理需求)是独特于资源受限、设备端机器学习的,因此本章提供了关于这些方面的建议。

了解需要哪些操作

本书侧重于在 TensorFlow 中创建的模型,因为作者在 Google 团队工作,但即使在一个框架内,创建模型的方式有很多不同。如果你查看语音命令训练脚本,你会看到它直接使用核心 TensorFlow 操作构建模型,并手动运行训练循环。这在当今是一种相当老式的工作方式(该脚本最初是在 2017 年编写的),而使用 TensorFlow 2.0 的现代示例可能会使用 Keras 作为一个高级 API,它会处理很多细节。

这样做的缺点是,从检查代码中不再明显地了解模型使用的底层操作。相反,它们将作为层的一部分被创建,这些层代表图中的较大块在一个调用中。这是一个问题,因为了解模型使用了哪些 TensorFlow 操作对于理解模型是否能在 TensorFlow Lite 中运行以及资源需求是非常重要的。幸运的是,即使从 Keras 中,只要可以使用tf.keras.backend.get_session()检索底层的Session对象,你仍然可以访问底层的低级操作。如果你直接在 TensorFlow 中编码,很可能已经将会话存储在一个变量中,所以下面的代码仍然有效:

for op in sess.graph.get_operations():
  print(op.type)

如果你将会话分配给了sess变量,这将打印出模型中所有操作的类型。你也可以访问其他属性,比如name,以获取更多信息。了解 TensorFlow 操作的存在将有助于在转换过程中到 TensorFlow Lite 时;否则,你看到的任何错误将更难理解。

查看 Tensorflow Lite 中现有操作的覆盖范围

TensorFlow Lite 仅支持 TensorFlow 的一部分操作,并且有一些限制。你可以在操作兼容性指南中查看最新列表。这意味着如果你计划创建一个新模型,你应该确保一开始就不依赖于不受支持的功能或操作。特别是,LSTMs、GRUs 和其他递归神经网络目前还不能使用。目前在完整的移动版本 TensorFlow Lite 和微控制器分支之间存在差距。了解当前 TensorFlow Lite for Microcontrollers 支持哪些操作的最简单方法是查看all_ops_resolver.cc,因为操作不断被添加。

在 TensorFlow 训练会话中显示的操作与 TensorFlow Lite 支持的操作进行比较可能会有点混淆,因为在导出过程中会发生几个转换步骤。例如,这些步骤将存储为变量的权重转换为常量,并可能将浮点操作量化为其整数等效项以进行优化。还有一些仅作为训练循环的一部分存在的操作,比如参与反向传播的操作,这些操作将被完全剥离。找出可能遇到的问题的最佳方法是在创建模型后立即尝试导出潜在模型,而不是在训练之前,这样您就可以在花费大量时间进行训练之前调整其结构。

将预处理和后处理移入应用代码

深度学习模型通常有三个阶段。通常有一个预处理步骤,可能只是从磁盘加载图像和标签并解码 JPEG,或者像将音频数据转换为频谱图这样复杂的语音示例。然后是一个核心神经网络,它接收值数组并以类似形式输出结果。最后,您需要在后处理步骤中理解这些值。对于许多分类问题,这只是将向量中的分数与相应的标签进行匹配,但是如果看一下像 MobileSSD 这样的模型,网络输出是一堆重叠的边界框,需要经过一个称为“非最大抑制”的复杂过程才能作为结果有用。

核心神经网络模型通常是计算量最大的部分,通常由相对较少的操作组成,如卷积和激活。预处理和后处理阶段通常需要更多的操作,包括控制流,尽管它们的计算负载要低得多。这意味着通常更合理的做法是将非核心步骤作为应用中的常规代码实现,而不是将它们嵌入到 TensorFlow Lite 模型中。例如,机器视觉模型的神经网络部分将接收特定尺寸的图像,如高 224 像素,宽 224 像素。在训练环境中,我们将使用 DecodeJpeg 操作,然后是 ResizeImages 操作将结果转换为正确的尺寸。然而,在设备上运行时,我们几乎肯定是从固定大小的源中获取输入图像,无需解压缩,因此编写自定义代码来创建神经网络输入比依赖库中的通用操作更有意义。我们可能还需要处理异步捕获,并可能从线程化所涉及的工作中获得一些好处。在语音命令的情况下,我们会做很多工作来缓存 FFT 的中间结果,以便在流式输入运行时尽可能重用尽可能多的计算。

并非每个模型在训练环境中都有显著的后处理阶段,但是在设备上运行时,通常希望利用随时间的连贯性来改善向用户显示的结果。即使模型只是一个分类器,唤醒词检测代码每秒运行多次并且 使用平均值 来提高结果的准确性是非常常见的。这种代码最好在应用级别实现,因为将其表达为 TensorFlow Lite 操作很困难,并且并不提供太多好处。虽然可能会看到在 detection_postprocess.cc 中,但是这需要在导出过程中从底层 TensorFlow 图中进行大量工作的连接,因为通常表达为 TensorFlow 中的小操作并不是在设备上实现它的有效方式。

这意味着您应该尝试排除图中的非核心部分,这将需要一些工作来确定哪些部分是哪些。我们发现Netron是一个很好的工具,可以用来探索 TensorFlow Lite 图,了解存在哪些操作,并了解它们是神经网络的核心部分还是仅仅是处理步骤。一旦了解内部发生的情况,您应该能够隔离核心网络,仅导出这些操作,并将其余部分实现为应用程序代码。

必要时实现所需操作

如果您发现有一些您绝对需要的 TensorFlow 操作在 TensorFlow Lite 中不受支持,那么可以将它们保存为 TensorFlow Lite 文件格式中的 自定义 操作,然后在框架内自行实现。完整的过程超出了本书的范围,但以下是关键步骤:

  • 使用启用 allow_custom_opstoco 运行,以便将不受支持的操作存储为序列化模型文件中的自定义操作。

  • 编写实现操作的内核,并在您的应用程序中使用的 op 解析器中使用 AddCustom() 进行注册。

  • 在调用 Init() 方法时,解压存储在 FlexBuffer 格式中的参数。

优化操作

即使您在新模型中使用了受支持的操作,您可能以尚未优化的方式使用它们。TensorFlow Lite 团队的优先事项受特定用例驱动,因此如果您正在运行一个新模型,可能会遇到尚未优化的代码路径。我们在第十五章中讨论了这一点,但正如我们建议您尽快检查导出兼容性一样——甚至在训练模型之前——确保在计划开发时间表之前获得所需的性能是值得的,因为您可能需要预留一些时间来处理操作延迟。

总结

训练一个新颖的神经网络以成功完成任务本身就具有挑战性,但要想构建一个能够产生良好结果并在嵌入式硬件上高效运行的网络更加困难!本章讨论了您将面临的一些挑战,并提供了克服这些挑战的方法建议,但这是一个庞大且不断增长的研究领域,因此我们建议查看第二十一章中的一些资源,看看是否有新的灵感来源可以用于您的模型架构。特别是,在这个领域,跟踪 arXiv 上最新的研究论文可能非常有用。

克服所有这些挑战后,您应该拥有一个小巧、快速、节能的产品,可以随时部署到现实世界中。在发布之前,值得考虑一下它可能对用户造成的潜在有害影响,因此第二十章涵盖了围绕隐私和安全的问题。

第二十章:隐私、安全和部署

在阅读本书之前的章节后,您希望能够构建一个依赖于机器学习的嵌入式应用程序。然而,要将您的项目转化为可以成功部署到世界上的产品,您仍然需要应对许多挑战。保护用户的隐私和安全是两个关键挑战。本章介绍了一些我们发现有用的方法来克服这些挑战。

隐私

设备上的机器学习依赖于传感器输入。其中一些传感器,如麦克风和摄像头,引发了明显的隐私问题,但甚至其他传感器,如加速计,也可能被滥用;例如,通过识别个人的步态来识别他们在使用您的产品时。作为工程师,我们都有责任保护用户免受产品可能造成的损害,因此在设计的各个阶段都要考虑隐私是至关重要的。处理敏感用户数据还涉及法律责任,超出了我们的范围,但您应该咨询您的律师。如果您是大型组织的一部分,您可能有隐私专家和流程可以帮助您获得专业知识。即使您无法获得这些资源,您也应该花一些时间在项目开始时进行自己的隐私审查,并定期重新审查,直到项目上线。关于“隐私审查”到底是什么,目前还没有广泛的共识,但我们讨论了一些最佳实践,其中大部分围绕着建立强大的隐私设计文档(PDD)。

隐私设计文档

隐私工程领域仍然非常新颖,很难找到关于如何处理产品隐私影响的文档。许多大公司处理确保应用程序隐私的过程的方式是创建一个隐私设计文档。这是一个单一的地方,您可以涵盖产品的重要隐私方面。您的文档应包括以下各小节提到的所有主题的信息。

数据收集

PDD 的第一部分应涵盖您将收集的数据、如何收集以及为什么收集。您应尽可能具体,并使用简单的英语,例如,“收集温度和湿度”而不是“获取环境大气信息”。在处理这一部分时,您还有机会思考您实际收集了什么,并确保这是您产品所需的最小数据。如果您只是在听大声噪音以唤醒更复杂的设备,您是否真的需要使用麦克风以 16 KHz 采样音频,还是可以使用一个更简单的传感器,确保即使发生安全漏洞也无法录制语音?在这一部分中,一个简单的系统图可以很有用,显示信息在产品中不同组件之间的流动(包括任何云 API)。这一部分的总体目标是向非技术人员提供对您将收集的内容的良好概述,无论是您的律师、高管还是董事会成员。一个思考方式是,如果由一位不友好的记者撰写的故事登在报纸头版上,会是什么样子。确保您已尽一切可能减少用户受到他人恶意行为的影响。具体而言,思考“一个虐待前任可能使用这项技术做什么?”等情景,并尽可能富有想象力,确保内置了尽可能多的保护措施。

数据使用

在收集数据后,对数据做了什么?例如,许多初创公司都会被诱惑利用用户数据来训练他们的机器学习模型,但从隐私角度来看,这是一个极其棘手的过程,因为它需要长时间存储和处理潜在非常敏感的信息,仅为间接用户利益。我们强烈建议将训练数据采集视为一个完全独立的程序,使用明确同意的付费提供者,而不是收集数据作为产品使用的副作用。

在设备上进行机器学习的好处之一是您有能力在本地处理敏感数据并仅共享聚合结果。例如,您可能有一个行人计数设备,每秒捕获图像,但传输的唯一数据是看到的人和车辆的计数。如果可以的话,尽量设计您的硬件以确保这些保证不会被打破。如果您只使用 224×224 像素图像作为分类算法的输入,使用一个分辨率低的摄像头传感器,以便无法识别面孔或车牌。如果您计划仅传输几个值作为摘要(如行人计数),请仅支持低比特率的无线技术,以避免即使您的设备被黑客入侵也无法传输源视频。我们希望未来,专用硬件将有助于执行这些保证,但即使现在,在系统设计层面仍有很多事情可以做,以避免过度设计并使滥用更加困难。

数据共享和存储

谁可以访问您收集的数据?有什么系统可以确保只有这些人可以看到它?数据会保留多长时间,无论是在设备上还是在云端?如果数据被保留了一段时间,删除政策是什么?您可能认为存储剥离了明显用户 ID(如电子邮件地址或姓名)的信息是安全的,但身份可以从许多来源推导出,比如 IP 地址、可识别的声音,甚至步态,因此您应该假设您收集的任何传感器数据都是个人可识别信息(PII)。最佳政策是将这种类型的 PII 视为放射性废物。如果可能的话,避免收集它,当您需要时要妥善保护它,并在完成后尽快处理它。

在考虑谁可以访问时,不要忘记所有您的许可系统都可以被政府压力覆盖,这可能会给您的用户在压制国家造成严重伤害。这是限制传输和存储到最低限度的另一个原因,以避免这种责任并限制用户的暴露。

同意

使用您的产品的人是否了解它正在收集什么信息,并且他们是否同意您将如何使用它?这里有一个狭窄的法律问题,您可能认为可以通过点击式最终用户许可协议来回答,但我们鼓励您将其更广泛地看作是一个营销挑战。假设您相信产品的好处值得收集更多数据,那么您如何清晰地向潜在客户传达这一点,以便他们做出知情选择?如果您在构思这条信息时遇到困难,那就是您应该重新考虑设计以减少隐私影响或增加产品的好处的迹象。

使用 PDD

您应该将 PDD 视为一份不断更新的活动文件,随着产品的发展而不断更新。显然,它对于向您的律师和其他业务利益相关者传达产品细节非常有用,但它在许多其他情境下也很有用。例如,您应该与您的营销团队合作,以确保其传达的信息是基于您正在做的事情,并与任何第三方服务提供商(如广告)合作,以确保他们遵守您所承诺的内容。团队中的所有工程师都应该可以访问它并添加评论,因为在实施层面可能会有一些隐藏的隐私影响。例如,您可能正在使用一个泄漏设备 IP 地址的地理编码云 API,或者您的微控制器上可能有一个未使用但理论上可以启用以传输敏感数据的 WiFi 芯片。

安全性

确保嵌入式设备的总体安全性非常困难。攻击者可以轻易获得系统的物理控制权,然后使用各种侵入性技术来提取信息。您的第一道防线是确保尽可能少的敏感信息保留在您的嵌入式系统上,这就是为什么 PDD 如此重要。如果您依赖与云服务的安全通信,您应该考虑调查安全加密处理器以确保任何密钥都安全保存。这些芯片还可以用于安全引导,以确保只有您刷写的程序才能在设备上运行。

与隐私一样,您应该努力设计硬件,以限制任何攻击者的机会。如果您不需要 WiFi 或蓝牙,构建一个没有这些功能的设备。不要在发货产品上提供像 SWD 这样的调试接口,并研究在 Arm 平台上禁用代码读取。尽管这些措施并不完美,但它们会增加攻击的成本。

您还应该尽量依赖已建立的库和服务来进行安全和加密。自行开发加密是一个非常糟糕的主意,因为很容易犯错误,而这些错误很难发现,但会破坏系统的安全性。嵌入式系统安全的全部挑战超出了本书的范围,但您应该考虑创建一个安全设计文档,类似于我们为隐私推荐的文档。您应该涵盖您认为可能的攻击、它们的影响以及您将如何防御它们。

保护模型

我们经常听到工程师们担心保护他们的机器学习模型免受不道德的竞争对手的侵害,因为这些模型需要大量工作来创建,但却被部署在设备上并且通常以易于理解的格式存在。坏消息是,没有绝对的保护免受复制。在这方面,模型就像任何其他软件:它们可以被窃取和检查,就像常规的机器码一样。然而,就像软件一样,问题并不像一开始看起来那么糟糕。就像反汇编过程式程序不会显示真正的源代码一样,检查量化模型也不会提供任何访问训练算法或数据的途径,因此攻击者将无法有效地修改模型以供其他用途。如果模型被部署在竞争对手的设备上,直接复制模型应该很容易被发现,并且可以在法律上证明竞争对手窃取了您的知识产权,就像您可以对任何其他软件做的那样。

让对您的模型进行非正式攻击变得更加困难可能是值得的。一种简单的技术是使用私钥对序列化模型进行 XOR 后存储在闪存中,然后在使用之前将其复制到 RAM 并解密。这将防止简单地转储闪存来揭示您的模型,但在运行时具有 RAM 访问权限的攻击者仍将能够访问它。您可能认为切换到专有格式而不是 TensorFlow Lite FlatBuffer 会有所帮助,但由于权重参数本身是大量数值数组,并且从调试器中逐步了解调用哪些操作以及顺序,我们发现这种混淆的价值非常有限。

注意

一种有趣的方法用于发现模型被盗用是在训练过程中故意引入微小缺陷,然后在检查疑似侵权时寻找它们。例如,您可以训练一个唤醒词检测模型,不仅监听“Hello”,还秘密监听“Ahoy, sailor!”。独立训练的模型极不可能对相同短语做出响应,因此如果有响应,这是模型被复制的强烈信号,即使您无法访问设备的内部工作原理。这种技术基于在参考作品中包含虚构条目的古老想法,例如地图、目录和字典,以帮助发现侵犯版权;它已经被称为mountweazeling,源自在地图上放置虚构山峰“Mountweazel”来帮助识别副本的做法。

部署

使用现代微控制器很容易启用空中更新,这样您就可以随时修改设备上运行的代码,甚至在发货后很久。这为安全和隐私侵犯打开了一个广泛的攻击面,我们敦促您考虑是否对您的产品真正必不可少。如果没有经过良好设计的安全引导系统和其他保护措施,很难确保只有您有能力上传新代码,如果出现错误,您就将完全将设备的控制权交给了恶意行为者。作为默认设置,我们建议在设备制造后不允许任何形式的代码更新。这可能听起来严厉,因为它阻止了修复安全漏洞的更新,但在几乎所有情况下,消除攻击者代码在系统上运行的可能性将更有利于安全,而不是有害。这也简化了网络架构,因为不再需要任何协议“监听”更新;设备可能有效地能够在仅传输模式下运行,这也大大减少了攻击面。

这意味着在设备发布之前,您需要更多地承担编写正确代码的责任,特别是关于模型准确性。我们之前谈到过像单元测试和针对专用测试集验证整体模型准确性等方法,但它们不会捕捉到所有问题。当您准备发布时,我们强烈建议使用一种自用方法,在这种方法中,您可以在真实环境中尝试设备,但在组织内部人员的监督下进行。这些实验更有可能揭示意外行为,而不是工程测试,因为测试受到其创建者的想象力的限制,而现实世界比我们任何人都能提前预测的要惊人得多。好消息是,在遇到不良行为之后,您可以将其转化为可以作为正常开发过程的一部分解决的测试用例。事实上,开发这种深入了解产品需求的机构记忆,并将其编码为测试,可能是您最大的竞争优势之一,因为获得这种优势的唯一方法是通过痛苦的试错。

从开发板转向产品

将在开发板上运行的应用程序转变为成品的完整过程超出了本书的范围,但在开发过程中有一些值得考虑的事项。您应该研究您考虑使用的微控制器的批量价格,例如在Digi-Key等网站上,以确保您最终的目标系统符合您的预算。假设您在开发过程中使用的是相同的芯片,将代码移植到生产设备应该相当简单,因此从编程的角度来看,主要任务是确保您的开发板与生产目标匹配。在您的代码以最终形式部署后,调试任何出现的问题将变得更加困难,尤其是如果您之前已经采取了保护平台的步骤,因此尽可能推迟这一步骤是值得的。

总结

保护用户的隐私和安全是我们作为工程师的最重要责任之一,但如何决定最佳方法并不总是清晰的。在本章中,我们涵盖了思考和设计保护措施的基本过程,以及一些更高级的安全考虑。通过这些内容,我们完成了构建和部署嵌入式机器学习应用的基础,但我们知道这个领域远不止我们在一本书中能涵盖的内容。最后一章讨论了您可以使用的资源,以继续学习更多。

第二十一章:进一步学习

我们希望这本书能帮助您解决重要的问题,使用廉价、低功耗的设备。这是一个新兴且快速增长的领域,所以我们在这里包含的内容只是一个快照。如果您想保持最新,这里有一些推荐的资源。

TinyML 基金会

TinyML 峰会是一年一度的会议,汇集了嵌入式硬件、软件和机器学习从业者,讨论跨学科合作。在湾区和德克萨斯州奥斯汀还有每月聚会,未来预计会有更多地点。即使无法亲临现场,您也可以在 TinyML 基金会网站上查看活动的视频、幻灯片和其他材料。

SIG Micro

本书专注于微控制器的 TensorFlow Lite,如果您有兴趣为框架做出贡献,有一个特别兴趣小组(SIG)可以让外部开发人员合作改进。SIG Micro 有公开的每月视频会议、邮件列表和 Gitter 聊天室。如果您对库中的新功能有想法或请求,这是一个讨论的好地方。您将看到所有参与项目的开发人员,包括谷歌内部和外部的开发人员,分享即将进行的工作的路线图和计划。任何更改的通常流程是从分享设计文档开始,对于简单更改,可以只是一页纸,涵盖为什么需要更改以及它将做什么。我们通常将其发布为 RFC(“请求评论”)以允许利益相关者提供反馈,然后在达成一致后,跟进包含实际代码更改的拉取请求。

TensorFlow 网站

主要的 TensorFlow 网站有一个用于微控制器工作的首页,您可以在那里查看最新的示例和文档。特别是,我们将在培训示例代码中继续迁移到 TensorFlow 2.0,所以如果您遇到兼容性问题,值得一看。

其他框架

我们专注于 TensorFlow 生态系统,因为这是我们最了解的库,但其他框架上也有很多有趣的工作正在进行。我们非常欣赏 Neil Tan 在 uTensor 上的开创性工作,该工作对从 TensorFlow 模型生成代码进行了许多有趣的实验。微软的嵌入式学习库支持除深度神经网络之外的大量不同机器学习算法,并且针对 Arduino 和 micro:bit 平台。

Twitter

您是否构建了一个嵌入式机器学习项目,想告诉全世界?我们很乐意看到您正在解决的问题,通过在 Twitter 上使用*#tinyml*标签分享链接是一个很好的联系方式。我们自己也在 Twitter 上,账号是@petewarden 和@dansitu,我们将在@tinymlbook 上发布有关本书的更新。

TinyML 的朋友们

在这个领域有很多有趣的公司,从初创公司到大公司。如果您正在开发产品,您会想探索他们提供的内容,所以这里是一些我们合作过的组织的按字母顺序排列的列表:

总结

感谢您加入我们探索嵌入式设备上的机器学习之旅。我们希望我们已经激发了您开展自己的项目,并且我们迫不及待地想看到您的成果,以及您如何推动这个令人兴奋的新领域向前发展!

附录 A:使用和生成 Arduino 库 Zip

Arduino IDE 要求源文件以一定的方式打包。TensorFlow Lite for Microcontrollers Makefile 知道如何为您做这件事,并且可以生成一个包含所有源文件的*.zip*文件,您可以将其导入到 Arduino IDE 作为库。这将允许您构建和部署您的应用程序。

在本节的后面会有生成此文件的说明。然而,开始的最简单方法是使用 TensorFlow 团队每晚生成的预构建*.zip*文件

在下载了该文件之后,您需要导入它。在 Arduino IDE 的 Sketch 菜单中,选择包含库→添加.ZIP 库,如图 A-1 所示。

“添加.ZIP 库…”菜单选项的屏幕截图

图 A-1. “添加.ZIP 库…”菜单选项

在出现的文件浏览器中,找到*.zip*文件,然后点击选择以导入它。

您可能希望自己生成库,例如,如果您对 TensorFlow Git 存储库中的代码进行了更改,并希望在 Arduino 环境中测试这些更改。

如果您需要自己生成文件,请打开终端窗口,克隆 TensorFlow 存储库,并切换到其目录:

git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow

现在运行以下脚本以生成*.zip*文件:

tensorflow/lite/micro/tools/ci_build/test_arduino.sh

文件将被创建在以下位置:

tensorflow/lite/micro/tools/make/gen/arduino_x86_64/ \
  prj/micro_speech/tensorflow_lite.zip

然后,您可以按照之前记录的步骤将此*.zip文件导入到 Arduino IDE 中。如果您之前安装了库,您需要先删除原始版本。您可以通过从 Arduino IDE 的libraries目录中删除tensorflow_lite*目录来实现这一点,您可以在 IDE 的首选项窗口中的“Sketchbook 位置”下找到它。