TensorBoard 通用数据提取#
@wchargin,2019-12-04
状态:正在实现。
目的#
磁盘上的摘要均采用张量形式*,但是来自不同插件的数据对应于不同的数据类——标量、张量或 blob 序列,它们来自通用数据 API 设计文档并在 DataProvider
Python 接口中指定。有些数据类不需要或几乎不需要预处理:例如,标量和直方图。其他一些数据类则需要转换:例如,对于音频,我们转换张量数据并将其嵌入到一个 blob 序列和一个张量时间序列中。
因此,我们需要某种机制,插件可以借助这种机制定义如何提取其数据。这种机制应同时用于本地 TensorBoard 网络服务器和 TensorBoard.dev 上传器。
对于旧数据,它们可能不会真正地存储为张量,但在加载过程中很早就由
data_compat.py
转换为这种形式,因此足以将所有数据视为 v2 样式的摘要。
样本映射#
为了做好准备,请考虑现有的第一方 TensorBoard 插件如何根据其当前将张量摘要写入磁盘的方式来提取数据:
标量:将数据断言为浮点数据类型的 0 秩张量。它的张量结构会被遗忘,并且它会被嵌入到标量时间序列中。
直方图:将数据断言为 [k, 3] 形状和浮点数据类型,并以无变化的形式嵌入到张量时间序列中。
PR 曲线:与直方图相同,但形状为 [6, k]。
图像:将数据断言为大小至少为 2 的 1 秩字节串张量,其中前两个元素包含图像宽度和高度的 ASCII 表示,后面的元素表示 PNG 编码的图像数据。此张量以无变化的形式*嵌入到 blob 序列时间序列中。
音频:与图像最为相似,但我们如何处理标签?一些用户确实需要它们(“包含文本注解的能力十分惊人”)。我们可以将它们存储在单独的张量摘要中,或许通过命名或摘要元数据将它们绑在一起。
文本:文本通常很短,因此我们只要求文本张量的总大小不超过 10 MiB。请注意,文本张量可以具有任意秩;高秩张量在前端呈现为表。如果我们确实需要支持任意大小的文本摘要,则可以将其另存为 blob 序列。
网格:网格的三个摘要(顶点摘要、面摘要和颜色摘要)中的每一个都将其张量数据独立编码为原始字节(打包的 f32/u32/u8 数组)或
TensorProto
等效项,并保存为 blob 序列。计算图:磁盘上的数据实际上略有不同,因为计算图并不总是使用张量摘要。传统上,模型计算图存储在
Event
原型的顶层graph_def
字段中。应当将其转换为具有固定标记名称(例如,__run_graph__
)且长度为 1 的 blob 序列,并使用制造的摘要元数据来指示其出处。Keras 概念计算图和性能分析数据表示为具有标记名称的摘要,因此应当嵌入具有适当内容且长度为 1 的 blob 序列中。为避免与固定的__run_graph__
标记名称发生名称冲突,应当按类型确定名称范围:用户提供的标记my_keras_model
应变为__model_graph__/my_keras_model
。(即,标记名称编码一个可区分联合。)超参数:可以作为 blob 序列或张量提取(所有数据实际上都在标记名称和摘要元数据中;张量内容为空)。但我们确实希望超参数是某种特殊的运行级元数据而不是摘要,因此,也许我们现在可以什么也不做。
我们也可以选择在此处降低维度,因为前端实际上并不需要它们,但是 (a) 我们可能以后要用到它们,并且 (b) 这基本上是互不相关的更改。如果存储系统 Blobstore 采用内容寻址,则额外的存储实际上是免费的,因为每个“维度”文件在所有用户之间仅存储一次。如果存储系统具有唯一所有者,那么尽管实际文件内容字节自然可以忽略不计,但我们需要为每个标准 3 样本摘要多创建 67% 的文件。在任何情况下,运行时的带宽成本都应当为零,因为 Python 插件不会在这两个元素上调用 read_blob。
机制结构#
我们提出了一种声明式机制,磁盘上的每个摘要都可以借助这种机制声明其适当的数据类。
SummaryMetadata
原型将获得一个枚举类型的新字段 DataClass
,它可以是“未知”(默认值)、“标量”、“张量”或“blob 序列”。所有新写入的数据都必须将此字段设置为与数据提供者兼容。读取时兼容层会将已知的第一方插件数据转换为这种新形式,这类似于将 v1 样式摘要以透明方式迁移到 v2 样式摘要的现有 data_compat.py
层。
协议缓冲区更改#
在 summary.proto
中,添加一个新的枚举类型:
enum DataClass {
DATA_CLASS_UNKNOWN = 0;
DATA_CLASS_SCALAR = 1;
DATA_CLASS_TENSOR = 2;
DATA_CLASS_BLOB_SEQUENCE = 3;
}
随后,扩展现有的 SummaryMetadata
以包括此类型的字段:
message SummaryMetadata { // (extend)
DataClass data_class = 4;
}
无论是显式还是隐式设置为零,标记摘要元数据上的 data_class
的值都会对相同时间序列的所有 Value
的张量值建立约束:
DATA_CLASS_UNKNOWN
的值为隐式默认值,并且数据提供者 API(包括 TensorBoard.dev 上传器和在通用数据模式下运行的本地版本 TensorBoard)将完全跳过相应的数据。对于
DATA_CLASS_SCALAR
,时间序列必须仅包含浮点数据类型的 0 秩张量,此张量将被向上转换到 f64 并被解释为标量。对于
DATA_CLASS_TENSOR
,时间序列的形状和数据类型不受约束,但总大小必须以中等常量(例如 10 MiB)为上限,如果大小超过实现定义的限制,实现可能会发出警告或失败。对于
DATA_CLASS_BLOB_SEQUENCE
,时间序列必须仅包含 1 秩字节串张量,这些张量以明显的方式转换为 blob 序列。
这些语义与多路复用器对标量和张量的处理方式一致,因此标量、直方图和分布信息中心的工作方式保持不变。
如果时间序列包含不满足其数据类约束的张量,则行为由实现定义。实现应向用户发出警告;此外,它们还可能会丢弃其余时间序列、丢弃整个时间序列或者完全输出错误。类似地,只有张量摘要支持这种数据格式,旧摘要(simple_value
等)则不支持。也就是说,可以认为 v1 到 v2 的 data_compat
转换受到了影响。
这是一种原型更改,因此需要更改 TensorFlow 代码。我们可以通过仅对 TensorBoard 兼容原型进行更改来构建原型,但我们有一种策略,除非我们的原型与 TensorFlow 的原型同步,否则我们不会执行 PyPI 发布。因此,我们必须在这里谨慎操作。
兼容层#
一个新的私有模块 tensorboard.dataclass_compat
公开了一个函数 migrate_event
,此函数接受 tensorboard.Event
原型,并返回要使用的一系列 tensorboard.Event
原型。
这种转换是无条件启用的,因此其输出应向后兼容。例如,设置了 graph_def
的事件应产生具有 blob 序列数据类的输出摘要,但也应产生原始的 graph_def
事件,以便在停用通用数据模式时计算图信息中心可以继续工作。
过去,这种透明读取时转换的一般方式对我们来说相当有效。tensorboard.data_compat
模块(客观上)几乎不需要维护,并且(主观上)既不会使心智模型显著复杂化,也不会提高认知负担。
标量、直方图、图像、文本和 PR 曲线的摘要转换只需设置 SummaryMetadata.data_class
字段即可,无需其他转换,这在两种情况(通用数据和直接多路复用器)下都适用。音频摘要转换在两种情况下会有所不同,因此我们应执行以下操作之一:
使用不同的
AudioPluginData.version
值(或类似值)同时发出旧形式和新形式。让旧的后端代码忽略未来版本,新的后端代码忽略旧版本。仅发出新形式,并将对这种格式的支持以原子方式向后移植到音频插件的非通用数据分支。
仅发出新形式,在音频插件中默认启用通用数据,并删除旧的代码路径。
后一个选项很有吸引力。由于 is_active
非常适合数据提供者(自 PR #3124 起),因此这可能是正确的做法。
实现此目的的一种明确方法是,首先作为满足正常向后兼容性保证的单独更改,将磁盘上的音频摘要形式更改为能够轻松嵌入到数据类范式中的形式,随后只需执行普通嵌入。换言之,让困难的更改变得简单,随后进行简单的更改。
连线#
plugin_event_accumulator.EventAccumulator
将被修改为在处理事件时调用 dataclass_compat
。此更改将立即对本地 TensorBoard 的所有用户生效。对于非通用读取路径,此更改可能是一个空运算,因为它们不需要检查 SummaryMetadata
上的新字段。由多路复用器提供支持的数据提供者将仅返回其已声明数据类与请求相匹配的数据:例如,对给定插件的 list_scalars
调用将仅列出具有 DATA_CLASS_TENSOR
的时间序列。
一旦 TensorBoard.dev 准备好存储系统,我们便会对上传器进行类似更改。上传器将需要对 SummaryMetadata.data_class
字段进行分析,以决定将哪些 RPC 发送到远程服务器。目前,上传流水线根本不使用多路复用器,而且情况可能会继续如此。
考虑的替代方案#
插件提供的转换代码#
我们可以在读取时要求插件提供对摘要进行分类的代码,而不是在写入时增加摘要来声明其数据类。一个插件将声明两个函数,例如:
ingest_time_series
:获取标记名称和摘要元数据。返回一系列Coproduct[ScalarTimeSeries, TensorTimeSeries, BlobSequenceTimeSeries]
或道德等效项:我们需要名称和元数据,但不需要最大步长或挂钟时间。我们不会将其折叠到下面的ingest_value
中,因为它仅在发生一次摘要元数据处理时才有意义。ingest_value
:获取标记名称和plugin_event_accumulator.TensorEvent
或道德等效项。返回一系列(output_tag_name, datum)
,其中datum
是ScalarDatum
、TensorDatum
或类似于BlobSequenceDatum
的值,除了这些值是实际的字节串(也可能是返回字节串的可调用对象),而不仅仅是键。
通过这种方式,插件作者可以灵活地进行设计转换,并且在迁移旧数据时,第一方和第三方插件基本上处于同一竞争环境。这些都是优点,但这种方式也有一些缺点。
从根本上讲,这是一种“面向代码”的方式,而写入时转换则是一种“面向数据”的方式。面向代码的方式将数据的解释耦合到特定的 Python API 和 Python 本身;Python 中存在的核心摘要逻辑确实比我们预期的要多。这种方式意味着要将数据从第三方插件加载到您的数据库中,您需要执行由该插件作者编写的任意代码,这会引起一种摘要仅声明其数据类时不存在的安全问题。特别是 Python,我们还担心此 API 可能会在某些地方强制执行不必要的复制,这会对关键数据加载流水线造成重大影响:TensorBoard 的数据加载速度是面向用户的一个现实问题。这种方式还会进一步巩固这样一种观念,即数据由插件拥有,而不是简单地具有某种格式(如 MIME 类型),因为每种类型的数据都需要具有一组规范的提取语义。总而言之,相对于引入任意命令式读取时转换,我们更倾向于采用自描述数据的声明式方式。
更复杂的转换#
如果我们暂时忽略已经保存到磁盘上的数据约束,而只考虑每个插件的数据类型的最“自然”形式,那么我们可能得出一些不同的结论。例如,与其将网格表示为顶点、面和颜色的三个独立摘要,不如存储以更特定于域的格式(如 Wavefront OBJ 或 Stanford PLY)编码的单个 blob。
执行此类转换以了解它们如何通知我们可能要公开的 API 十分必要。例如,这种网格转换需要一种允许将多个输入时间序列组合为一个输出时间序列的映射机制。考虑到输入的各个组件在读取间隔方面原则上可以很长,这似乎会带来明显的约束,而且这种单一用例的吸引力不足以获得支持。
原则上,我们可以利用这个机会对数据表示形式进行此类“无关”更改,但最好还是单独实行这些更改。
可感知数据类的数据提供者#
通过为不受现有多路复用器基础架构支持的本地 TensorBoard 数据提供者接口创建新实现,可以避免其中一部分工作。这种实现从一开始就可以“感知数据类”。它的内存中存储可与通用数据本体以及“标量”、“张量”和“blob 序列”的单独集合更紧密地对齐,这些数据或许还会用插件名称分隔。这可能在读取时更高效。它可以拥有更好的下采样策略。它不会以 Eager 模式将较大的 blob 读入内存,而是将字节偏移量存储到事件文件中,随后在实际请求时查找它们。这有助于提高加载速度、缓解内存压力,并让我们可以默认减少下采样。对于由实际数据库提供支持的高权重提供者,将此新数据提供者作为“参考实现”来读取也更加容易。
不利的一面是,这需要提前完成额外的工作:用全新的数据提供者替换现有的蓄水池、累加器和多路复用器代码。现有代码约有 3000 行,其中一些有用的功能可能会在直通重写中被丢弃:对目录删除的适当支持;检测并调整乱序和孤立的数据;多线程加载。失去这种“改进”功能后,将所有用户主动切换到新的数据提供者会更加困难,这反过来会增加其风险。
我们当然应该对创建可原生感知数据类的提供者保持开放态度,因为它具有真正的潜在好处。但是,我们不需要将其与此工作相结合。一旦数据提供者 API 稳定下来并且我们可以更详细地了解它们,我们便可以实现它,同时利用其自己的独立功能标记来控制它。此外,我们还可以修复多路复用器中的并发错误和漏洞,而无需完全丢弃代码。:-)
参考文献#
更新日志#
2020-01-08:添加了“可感知数据类的数据提供者”。是否采用建议的简化迁移路径取决于是否默认启用通用数据模式。
2020-01-02:征求公众意见的初始版本。