2024年11月

全网最适合入门的面向对象编程教程:58 Python 字符串与序列化-序列化 Web 对象的定义与实现

image

摘要:

如果我们要在不同的编程语言之间传递对象,就必须把对象序列化为标准格式,比如XML\YAML\JSON格式这种序列化Web对象。这种序列化Web对象容易与其他程序设计语言交互,可读性强,容易被传递给其它系统或客户端。

原文链接:

FreakStudio的博客

往期推荐:

学嵌入式的你,还不会面向对象??!

全网最适合入门的面向对象编程教程:00 面向对象设计方法导论

全网最适合入门的面向对象编程教程:01 面向对象编程的基本概念

全网最适合入门的面向对象编程教程:02 类和对象的 Python 实现-使用 Python 创建类

全网最适合入门的面向对象编程教程:03 类和对象的 Python 实现-为自定义类添加属性

全网最适合入门的面向对象编程教程:04 类和对象的Python实现-为自定义类添加方法

全网最适合入门的面向对象编程教程:05 类和对象的Python实现-PyCharm代码标签

全网最适合入门的面向对象编程教程:06 类和对象的Python实现-自定义类的数据封装

全网最适合入门的面向对象编程教程:07 类和对象的Python实现-类型注解

全网最适合入门的面向对象编程教程:08 类和对象的Python实现-@property装饰器

全网最适合入门的面向对象编程教程:09 类和对象的Python实现-类之间的关系

全网最适合入门的面向对象编程教程:10 类和对象的Python实现-类的继承和里氏替换原则

全网最适合入门的面向对象编程教程:11 类和对象的Python实现-子类调用父类方法

全网最适合入门的面向对象编程教程:12 类和对象的Python实现-Python使用logging模块输出程序运行日志

全网最适合入门的面向对象编程教程:13 类和对象的Python实现-可视化阅读代码神器Sourcetrail的安装使用

全网最适合入门的面向对象编程教程:全网最适合入门的面向对象编程教程:14 类和对象的Python实现-类的静态方法和类方法

全网最适合入门的面向对象编程教程:15 类和对象的 Python 实现-__slots__魔法方法

全网最适合入门的面向对象编程教程:16 类和对象的Python实现-多态、方法重写与开闭原则

全网最适合入门的面向对象编程教程:17 类和对象的Python实现-鸭子类型与“file-like object“

全网最适合入门的面向对象编程教程:18 类和对象的Python实现-多重继承与PyQtGraph串口数据绘制曲线图

全网最适合入门的面向对象编程教程:19 类和对象的 Python 实现-使用 PyCharm 自动生成文件注释和函数注释

全网最适合入门的面向对象编程教程:20 类和对象的Python实现-组合关系的实现与CSV文件保存

全网最适合入门的面向对象编程教程:21 类和对象的Python实现-多文件的组织:模块module和包package

全网最适合入门的面向对象编程教程:22 类和对象的Python实现-异常和语法错误

全网最适合入门的面向对象编程教程:23 类和对象的Python实现-抛出异常

全网最适合入门的面向对象编程教程:24 类和对象的Python实现-异常的捕获与处理

全网最适合入门的面向对象编程教程:25 类和对象的Python实现-Python判断输入数据类型

全网最适合入门的面向对象编程教程:26 类和对象的Python实现-上下文管理器和with语句

全网最适合入门的面向对象编程教程:27 类和对象的Python实现-Python中异常层级与自定义异常类的实现

全网最适合入门的面向对象编程教程:28 类和对象的Python实现-Python编程原则、哲学和规范大汇总

全网最适合入门的面向对象编程教程:29 类和对象的Python实现-断言与防御性编程和help函数的使用

全网最适合入门的面向对象编程教程:30 Python的内置数据类型-object根类

全网最适合入门的面向对象编程教程:31 Python的内置数据类型-对象Object和类型Type

全网最适合入门的面向对象编程教程:32 Python的内置数据类型-类Class和实例Instance

全网最适合入门的面向对象编程教程:33 Python的内置数据类型-对象Object和类型Type的关系

全网最适合入门的面向对象编程教程:34 Python的内置数据类型-Python常用复合数据类型:元组和命名元组

全网最适合入门的面向对象编程教程:35 Python的内置数据类型-文档字符串和__doc__属性

全网最适合入门的面向对象编程教程:36 Python的内置数据类型-字典

全网最适合入门的面向对象编程教程:37 Python常用复合数据类型-列表和列表推导式

全网最适合入门的面向对象编程教程:38 Python常用复合数据类型-使用列表实现堆栈、队列和双端队列

全网最适合入门的面向对象编程教程:39 Python常用复合数据类型-集合

全网最适合入门的面向对象编程教程:40 Python常用复合数据类型-枚举和enum模块的使用

全网最适合入门的面向对象编程教程:41 Python常用复合数据类型-队列(FIFO、LIFO、优先级队列、双端队列和环形队列)

全网最适合入门的面向对象编程教程:42 Python常用复合数据类型-collections容器数据类型

全网最适合入门的面向对象编程教程:43 Python常用复合数据类型-扩展内置数据类型

全网最适合入门的面向对象编程教程:44 Python内置函数与魔法方法-重写内置类型的魔法方法

全网最适合入门的面向对象编程教程:45 Python实现常见数据结构-链表、树、哈希表、图和堆

全网最适合入门的面向对象编程教程:46 Python函数方法与接口-函数与事件驱动框架

全网最适合入门的面向对象编程教程:47 Python函数方法与接口-回调函数Callback

全网最适合入门的面向对象编程教程:48 Python函数方法与接口-位置参数、默认参数、可变参数和关键字参数

全网最适合入门的面向对象编程教程:49 Python函数方法与接口-函数与方法的区别和lamda匿名函数

全网最适合入门的面向对象编程教程:50 Python函数方法与接口-接口和抽象基类

全网最适合入门的面向对象编程教程:51 Python函数方法与接口-使用Zope实现接口

全网最适合入门的面向对象编程教程:52 Python函数方法与接口-Protocol协议与接口

全网最适合入门的面向对象编程教程:53 Python字符串与序列化-字符串与字符编码

全网最适合入门的面向对象编程教程:54 Python字符串与序列化-字符串格式化与format方法

全网最适合入门的面向对象编程教程:55 Python字符串与序列化-字节序列类型和可变字节字符串

全网最适合入门的面向对象编程教程:56 Python字符串与序列化-正则表达式和re模块应用

全网最适合入门的面向对象编程教程:57 Python字符串与序列化-序列化与反序列化

更多精彩内容可看:

给你的 Python 加加速:一文速通 Python 并行计算

一文搞懂 CM3 单片机调试原理

肝了半个月,嵌入式技术栈大汇总出炉

电子计算机类比赛的“武林秘籍”

一个MicroPython的开源项目集锦:awesome-micropython,包含各个方面的Micropython工具库

Avnet ZUBoard 1CG开发板—深度学习新选择

SenseCraft 部署模型到Grove Vision AI V2图像处理模块

文档和代码获取:

可访问如下链接进行对文档下载:

https://github.com/leezisheng/Doc

image

本文档主要介绍如何使用 Python 进行面向对象编程,需要读者对 Python 语法和单片机开发具有基本了解。相比其他讲解 Python 面向对象编程的博客或书籍而言,本文档更加详细、侧重于嵌入式上位机应用,以上位机和下位机的常见串口数据收发、数据处理、动态图绘制等为应用实例,同时使用 Sourcetrail 代码软件对代码进行可视化阅读便于读者理解。

相关示例代码获取链接如下:
https://github.com/leezisheng/Python-OOP-Demo

正文

序列化 Web 对象

如果我们要在不同的编程语言之间传递对象,就必须把对象序列化为标准格式,比如 XML\YAML\JSON 格式这种序列化 Web 对象。这种序列化 Web 对象容易与其他程序设计语言交互,可读性强,容易被传递给其它系统或客户端。

在 Python 最常用的序列化 Web 对象就是环境配置的 yaml 文件,anaconda 可以管理不同的环境配置,当我们想将自己的环境配置分享给其他人时,就可以生成 yaml 文件,这样别人可以快速导入 yaml 文件构建和我们一样的环境来运行代码。

我们可以在命令行中使用如下指令生成 yaml 文件:

conda env export > environment.yml

image

使用 JSON 实现序列化 Web 对象

JavaScript Object Notation(JSON)是一种人类可读的格式,用于存储基础数据类型。JSON 是一种标准格式,可以被各式各样的客户端系统解析。因此,JSON 非常适合用于在完全不同的系统之间进行数据传输。而且,JSON 不支持任何可执行代码,只有数据可以被序列化;因此,更难向其中植入恶意代码。JSON 格式因其易于被 JavaScript 引擎解析的特性,常被用于 Web 服务器与具备 JavaScript 功能的浏览器间的数据传输。若 Web 应用的服务器端采用 Python 编写,为确保数据的兼容性与通用性,需将内部数据转换为 JSON 格式。

我们可以使用 Python 中的 json 模块生成 JSON 文件,该模块与 pickle 类似,也提供了 dumps()、dump()、loads()、load()四个函数,但输出结果是 JSON 格式的。此外,
json 函数作用于 str 对象,而不是 bytes。因此,当输出或载入时,我们需要创建文本模式的文件而不是二进制模式。
JSON 只能序列化基本类型,如整数、浮点数和字符串,以及简单的容器,如字典和列表。这些都直接映射到 JSON 形式,不过** JSON 不能表示类、方法或函数。不能用这种格式来传输完整的对象。因为接收者通常不是 Python 对象,接收者不能与 Python 以同样的方式来理解类或方法。**

image

image

image

image

import json

_# 人员信息列表_
humaninfodic={
    'age'   : 18,
    'name'  : True,
    'gender': 10,
    'email' : 11.1,
}

_# 序列化到文件中_
with open('test.json', 'w') as fp:
    json.dump(humaninfodic, fp, indent=4)

_# 反序列化文件中的内容_
with open('test.json', 'r') as fp:
    dic = json.load(fp)
    print(dic)

运行结果如下:

image

image

这里,需要注意的是,Python 中字典的非字符的值 Key 被转换成 JSON 字符串时都会被转换为小写字符串,例如 True 会被映射为 true,False 被映射为 false,而 None 会被映射为 null;同时 Python 中的元组在序列化时会被转换为 array 类型,但是反序列化时,array 类型会被转化为 Python 中的列表类型。

JSON 和 Python 内置的数据类型对应如下:

JSON 类型 Python 类型
{} dict
[] list
"string" str
1234.56 int 或 float
true/false True/False
null None

如果想要序列化的对象只有数据,我们可以直接序列化对象的__dict__属性。或者我们也可以针对特定的对象,通过自定义代码来创建或解析 JSON 序列化字典。

image

image

在 json 模块中,对象的存储和载入功能均设有一个可选参数,用以执行特定的自定义操作。具体来说,
dump

dumps
方法接受一个名为
cls
(即“class”的简写,因其为 Python 的保留关键字)的关键字参数。

当传递此参数时,它必须是
JSONEncoder
的一个子类,并且要求重写了
default
方法。
这个方法的设计初衷是接收任意类型的对象作为参数,然后将其转换为 json 能够处理的字典类型。
如果在处理过程中遇到不知道如何处理的对象类型,可以通过调用
super()
方法,使得该对象能够按照正常的方式进行序列化处理,即按照基本类型进行序列化。

同样地,
load

loads
方法也接受一个名为
cls
的参数,它是
JSONDecoder
的一个子类。通常情况下,通过
object_hook
关键字传递一个函数便足够了。

这个函数的任务是接收一个字典作为参数,并返回一个对象。如果在处理过程中遇到不知道如何处理的字典,可以选择不进行任何修改,直接将其返回。这种设计使得用户在进行 json 数据的解析时,能够更加灵活和方便地处理各种复杂的数据类型。

import json

_# 定义联系人类_
class Contact:
    def __init__(self, first, last):
        _# 属性1,first name是名字_
        self.first = first
        _# 属性2,last name是姓氏_
        self.last = last
    @property
    def full_name(self):
        return("{} {}".format(self.first, self.last))

_# 自定义序列化编码器类_
class ContactEncoder(json.JSONEncoder):
    _# default 方法检查了我们想要序列化的对象类型_
    def default(self, obj):
        _# 如果是联系人类,我们手动将其转换为字典_
        if isinstance(obj, Contact):
            return {
                    _# 传递了一个额外的属性来说明这是一个联系人对象_
                    _# 因为没有其他办法可以在载入之后知道它的类型_
                    'is_contact': True,
                    'first': obj.first,
                    'last': obj.last,
                    'full': obj.full_name}
        _# 否则,让其父类来处理序列化(假设它是基本类型,json 知道如何处理)_
        return super().default(obj)

_# 定义一个JSON文件解码器函数_
def decode_contact(dic):
    _# 写一个函数接受字典为参数_
    _# 检查是否包含 is_contact 变量来决定是否将其转换为联系人_
    if dic.get('is_contact'):
        return Contact(dic['first'], dic['last'])
    else:
        return dic

if __name__ == '__main__':
    c = Contact("John", "Smith")
    data = json.dumps(c, cls=ContactEncoder)
    print(data)
    c = json.loads(data, object_hook=decode_contact)
    print(c.full_name)

运行结果如下:

image

使用 XML 实现序列化 Web 对象

在当今的软件开发领域,
XML 作为一种灵活且强大的标记语言,已经广泛应用于数据存储、配置管理、网络传输等多个场景。
它的可扩展性和自描述性让它成为了不同系统和平台之间数据交换的理想格式。

XML 指可扩展标记语言(eXtensible Markup Language),是一套定义语义标记的规则
,这些标记将文档分成许多部件并对这些部件加以标识,
它被设计用来传输和存储数据,不用于表现和展示数据。
它也是
元标记语言,即定义了用于定义其他与特定领域有关的、语义的、结构化的标记语言的句法语言。

如下我们列举了一个 xml 文件的例子:

<?xml version="1.0" encoding="utf-8"?>
<catalog>
    <maxid>4</maxid>
    <login username="pytest" passwd='123456'>
        <caption>Python</caption>
        <item id="4">
            <caption>测试</caption>
        </item>
    </login>
    <item id="2">
        <caption>Zope</caption>
    </item>
</catalog>

以上 XML 格式的文件中,包含了一个名为"catalog"的根元素。根元素下有一个名为"maxid"的子元素,其值为"4"。接着是一个名为"login"的子元素,它包含两个子元素:一个名为"caption"的子元素,其值为"Python";另一个名为"item"的子元素,其 id 属性值为"4",包含一个名为"caption"的子元素,其值为"测试"。最后还有一个名为"item"的子元素,其 id 属性值为"2",包含一个名为"caption"的子元素,其值为"Zope"。

其中书名号圈住的部分“
”为标签,标签必须成对出现,有开始标签就需要有结束标签,例如” “为开始标签,“ ”为结束标签。

一个基本的 XML 文档结构包括以下部分:

结构部分 作用
声明部分 位于文档的最开始,声明 XML 的版本和编码方式。例如:。
根元素 每个 XML 文档都有一个根元素,它包含了所有其他元素。
子元素 根元素内部可以包含多个子元素,子元素可以嵌套并形成树状结构。
属性 元素可以有属性,属性提供了关于元素的额外信息。
文本内容 元素可以包含文本内容。

Python 作为一门简洁而强大的编程语言,提供了丰富的库来处理 XML 数据,使得从解析到修改再到创建 XML 文档变得既简单又高效。Python 有三种方法解析 XML、SAX、DOM,以及 ElementTree。

image

其中,xml.etree.ElementTree(简称 ET)提供了一个轻量级的 Pythonic 方式来处理 XML 数据。ET 允许用户轻松地读取、修改和创建 XML 文件。由于是标准库的一部分,因此不需要额外安装即可使用,这使得它成为处理 XML 数据的一个便捷选择。

对于 ElementTree 库来说,常见使用操作包括解析 XML 文档,获取根元素、遍历子元素、读取元素的标签、文本和属性,以及如何根据需要获取或删除特定元素,以及保存修改后的 XML 文档。

这里,对于 XML 文件仅作为了解使用,并不展开进行讲解。

使用 YAML 实现序列化 Web 对象

XML 文件虽然功能强大,但由于其标记语言的特性,通常不太易于阅读。相反,XML 在需要对验证、架构和名称空间进行精细控制的复杂项目中表现出色。
与 XML 相比,YAML 则专注于数据格式化,以可读代码的形式呈现,其内联风格与 JSON 颇为相似。YAML 旨在提供更为直观和易读的数据表示方式,以满足不同场景下的需求。

YAML,作为一种高度人性化的数据序列化语言,能够与当前主流的编程语言无缝集成。其名称“YAML”来源于“YAML Ain't a Markup Language”(YAML 不是一种标记语言)的递归缩写,这一命名既体现了其独特性,又突显了其与众不同的设计理念。

YAML 的语法结构与其他高级语言相似,能够轻松表达清单、散列表以及标量等多种数据形态。它巧妙地利用空白符号进行缩进,并大量依赖外观特征来展示数据结构的层次关系。
这种设计使得 YAML 特别适合用于编辑数据结构、编写各种配置文件、打印调试信息以及呈现文件大纲等场景。

此外,YAML 配置文件通常以“.yml”作为文件后缀,这一命名约定有助于用户快速识别和管理 YAML 文件。

它的基本语法规则如下:

(1)大小写敏感;

(2)使用缩进表示层级关系;

(3)缩进时不允许使用 Tab 键,只允许使用空格。

(4)缩进的空格数目不重要,只要相同层级的元素左侧对齐即可

YAML 支持的数据结构有三种:

(1)对象:键值对的集合,又称为映射(mapping)/ 哈希(hashes) / 字典(dictionary);

(2)数组:一组按次序排列的值,又称为序列(sequence) / 列表(list);

(3)纯量(scalars):单个的、不可再分的值。

在 Python 中,有多个库可用于解析和生成 YAML 数据,其中最常用的是 PyYAML。

image

这里,我们以下列名为 config.yaml 的 YAML 配置文件为例,简单讲解一下 YAML 文件语法:

_# 配置文件示例_
  
_# 服务器配置_
server:  
  host: localhost  
  port: 8080  

_# 数据库配置_
database:  
  type: MySQL  
  host: 127.0.0.1  
  port: 3306  
  username: root  
  password: password  

_# 日志记录_
logging:  
  level: info  
  file: app.log

_# 应用配置_
app:
  debug: true
  log_level: info

这里,该文件使用井号(#)表示注释,使用缩进表示层级关系,并且使用冒号(:)分隔键和值。

这个 YAML 文件包含以下几个部分:

(1)服务器配置:包括主机名和端口号;

(2)数据库配置:包括数据库类型、主机名、端口号、用户名和密码;

(3)日志记录:包括日志级别和日志文件路径;

(4)应用配置:包括是否开启调试模式和日志级别。

XML 和 YAML 文件都有一些复杂的特征,如果被恶意利用,就可以允许在主机上执行任意命令。
这与 JSON 文件不同,JSON 不支持任何可执行代码,只有数据可以被序列化。

image

单例模式介绍

单例模式是一种创建型设计模式,它主要确保在一个类只有一个实例,并提供一个全局访问点来获取该实例。在C#中,有多种方式实现单例模式,每种方式都有其特定的使用场景和注意事项。

设计模式的作用

  • 提高代码的可重用性:通过定义一套标准的解决方案,设计模式使得相同或类似的问题可以在不同的项目中复用相同的代码结构或逻辑。
  • 增强代码的可读性:设计模式使用清晰、简洁的方式表达复杂的代码逻辑,使得其他开发者能够更容易地理解和维护代码。
  • 提高系统的可维护性:设计模式遵循一定的设计原则,如开闭原则、里氏代换原则等,这些原则有助于降低系统各部分的耦合度,提高系统的可扩展性和可维护性。

饿汉式单例模式

饿汉式单例是在类加载时就创建实例。优点是实现简单,缺点是如果该实例不被使用会造成资源浪费。

        /// <summary>
        /// 饿汉式单例模式
        /// </summary>
        public class SingletonEager
        {
            private SingletonEager() { }

            private static readonly SingletonEager _instance = new SingletonEager();

            public static SingletonEager Instance
            {
                get { return _instance; }
            }

            public void DoSomething()
            {
                Console.WriteLine("饿汉式单例模式.");
            }
        }

懒汉式单例模式

懒汉式单例在第一次被访问时才创建实例。为了线程安全,通常需要使用锁机制。

        /// <summary>
        /// 懒汉式单例模式
        /// </summary>
        public class SingletonLazy
        {
            private SingletonLazy() { }

            private static SingletonLazy? _instance;

            private static readonly object _lockObj = new object();

            public static SingletonLazy Instance
            {
                get
                {
                    if (_instance == null)
                    {
                        lock (_lockObj)
                        {
                            if (_instance == null)
                            {
                                _instance = new SingletonLazy();
                            }
                        }
                    }
                    return _instance;
                }
            }

            public void DoSomething()
            {
                Console.WriteLine("懒汉式单例模式.");
            }
        }

懒加载单例模式

如果您使用的是 .NET 4(或更高版本),可以使用Lazy类来实现线程安全的懒加载单例模式。

        /// <summary>
        /// 懒加载单例模式
        /// </summary>
        public sealed class SingletonByLazy
        {
            private static readonly Lazy<SingletonByLazy> _lazy = new Lazy<SingletonByLazy>(() => new SingletonByLazy());

            public static SingletonByLazy Instance { get { return _lazy.Value; } }

            private SingletonByLazy() { }

            public void DoSomething()
            {
                Console.WriteLine("懒加载单例模式.");
            }
        }

设计模式入门实战教程

https://mp.weixin.qq.com/s/FM0ThUR92EcXJ3YY313ifw

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

并行收集:
指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

并发收集:
指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

吞吐量:
即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

串行Serial / Serial Old 收集器

特点:单线程、简单高效(与其他收集器的单线程相比),采用
复制算法
。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)参数:-XX:+UseSerialGC -XX:+UseSerialOldGC

安全点:
让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态

Serial Old
是Serial收集器的老年代版本:采用
标记整理
算法

特点:

  • 单线程收集器


    • 收集效率高,不会产生对象引用变更

    • STW时间长

  • 使用场景:适合内存小几十兆以内,比较适合简单的服务或者单CPU服务,避免了线程交互的开销。

  • 优点:小堆内存且单核CPU执行效率高。

  • 缺点:堆内存大,多核CPU不适合,回收时长非常长。

ParNew 收集器

年轻代:-XX:+UserParNewGC 老年代搭配 CMS

ParNew收集器其实就是Serial收集器的多线程版本

  • 特点:
    多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题

CMS 收集器

老年代:-XX:+UserConcMarkSweepGC年轻代搭配ParNew

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器

  • 特点:
    基于
    标记清除算法
    实现。并发收集、低停顿,但是会产生内存碎片

运行过程分分为下列4步:

  1. 初始标记:
    标记GCRoots直接关联的对象以及年轻代指向老年代的对象,会发生Stop the word。但是这个阶段的速度很快,因为没有向下追溯,即只标记一层。

例如:Math math = new Math();此时new Math()即为math的直接引用对象,再往下为间接引用不做记录,例如构造方法中引用了其他成员变量

  1. 并发标记:
    接着从gc roots的直接引用对象开始遍历整条引用链并进行标记,此过程耗时较长,但无需停顿用户线程,可与垃圾收集线程一起并发运行。由于用户线程继续运行,因此可能会导致已经标记过的对象状态发生改变,这个阶段采用
    三色标记算法
    , 在对象头(Mark World)标识了一个颜色属性,不同的颜色代表不同阶段,扫描过程中给与对象一个颜色,记录扫描位置,防止cpu时间片切换不需要重新扫描。
  2. 重新标记:
    为了
    修正并发标记
    期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题,这里会慢一些
  3. 并发清理:
    标记结束之后开启用户线程,同时垃圾收集线程也开始对未标记的区域进行清除,此阶段若有新增对象则会被标记为黑色,不做任何处理

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

  • 应用场景:
    适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务

  • 优点:


    • 并发收集;

    • STW时间相对短,低停顿;

  • 缺点:


    • 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
    • 内存碎片问题:CMS本质上是实现了标记清除算法的收集器,这意味着会产生内存碎片,当碎片化非常严重的时候,这时候有大对象进入无法分配内存时会触发FullGC,特殊场景下会使用Serial收集器,导致停顿不可控。
    • 无法处理浮动垃圾,需要预留空间,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,会导致停顿时间更长

三色标记算法

三色标记

  • 黑色:代表了自己已经被扫描完毕,并且自己的引用对象也已经确定完毕。

  • 灰色:代表自己已经被扫描完毕了, 但是自己的引用还没标记完。

  • 白色:则代表还没有被扫描过。标记过程结束后,所有未被标记的对象都是不可达的,可以被回收。

三色标记算法的
问题场景
:当业务线程做了对象引用变更,会发生B对象不会被扫描,当成垃圾回收。

public class Demo3 {
 
    public static void main(String[] args) {
        R r = new R();
        r.a = new A();
        B b = new B();
        // GCroot遍历R, R为黑色, R下面的a引用链还未扫完置灰灰色,R.b无引用, 切换时间分片
        r.a.b = b;
        // 业务线程发生了引用改变, 原本r.a.b的引用置为null
        r.a.b = null;
        // GC线程回来继续上次扫描,发现r.a.b无引用,则认为b对象无任何引用清除
        r.b = b;
        // GC 回收了b, 业务线程无法使用b
    }
}
 
class R {
    A a;
    B b;
}
 
class A {
    B b;
}
 
class B {
}

当GC线程标记A时,CPU时间片切换,业务线程进行了对象引用改变,这时候时间片回到了GC线程,继续扫描对象A, 发现A没有任何引用,则会将A赋值黑色扫描完毕,这样B则不会被扫描,会标记B是垃圾, 在清理阶段将B回收掉,错误的回收正常的对象,发生业务异常。

CMS基于这种错误标记的解决方案是采取
写屏障 + 增量更新Incremental Update
, 在业务线程发生对象变化时,重新将R标识为灰色,重新扫描一遍,Incremental Update 在特殊场景下还是会产生漏标。即当黑色对象被新增一个白色对象的引用的时候,记录下发生引用变更的黑色对象,并将它重新改变为灰色对象,重新标记。

public class Demo3 {
 
    public static void main(String[] args) {
        // Incremental Update还会产生的问题
        R r = new R();
        A a = new A();
        A b = new A();
        r.a1 = a;
        // GC线程切换, r扫完a1, 但是没有扫完a2, 还是灰色
        r.a2 = b;
        // 业务线程发生引用切换, r置灰灰色(本身灰色)
        r.a1 = b;
        // GC线程继续扫完a2, R为黑色, b对象又漏了~
    }
}
 
class R {
    A a1;
    A a2;
}
 
class A {
}

当GC 1线程正在标记O, 已经标记完O的属性 O.1, 准备标记O.2时,业务线程把属性O,1 = B,这时候将O对象再次标记成灰色, GC 1线程切回,将O.2线程标记完成,这时候认为O已经全部标记完成,O标记为黑色, B对象产生了漏标, CMS针对Incremental Update产生的问题,只能在remark阶段,暂停所有线程,将这些发生过引用改变过的,重新扫描一遍。

吞吐量优先Parallel

  • 多线程

  • 堆内存较大,多核CPU

  • 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短

  • JDK1.8默认使用的垃圾回收器

Parallel Scavenge 收集器

新生代收集器,基于
复制算法
实现的收集器。特点是吞吐量优先,故也称为吞吐量优先收集器,能够并行收集的多线程收集器,允许多个垃圾回收线程同时运行,降低垃圾收集时间,提高吞吐量。 Parallel Scavenge 收集器关注点是吞吐量,高效率的利用 CPU 资源。 CMS 垃圾收集器关注点更多的是用户线程的停顿时间。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的

-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。

  • -XX:MaxGCPauseMillis 参数的值是一个大于0的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定值。

  • -XX:GCTimeRatio 参数的值大于0小于100,即垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:
GC自适应调节策略
(与ParNew收集器最重要的一个区别)

GC自适应调节策略:
Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

  • 使用场景:适用于内存在几个G之间,适用于后台计算服务或者不需要太多交互的服务,保证吞吐量的服务。

  • 优点:可控吞吐量、保证吞吐量,并行收集。

  • 缺点:回收期间STW,随着堆内存增大,回收暂停时间增大。

Parallel Old 收集器

是Parallel Scavenge收集器的老年代版本

特点:多线程,采用
标记整理算法
(老年代没有幸存区)

  • 响应时间优先

  • 多线程

  • 堆内存较大,多核CPU

  • 尽可能让单次STW时间变短(尽量不影响其他线程运行)

面试题专栏

Java面试题专栏
已上线,欢迎访问。

  • 如果你不知道简历怎么写,简历项目不知道怎么包装;
  • 如果简历中有些内容你不知道该不该写上去;
  • 如果有些综合性问题你不知道怎么答;

那么可以私信我,我会尽我所能帮助你。

how-to-use-gpu

本文主要分享在不同环境,例如裸机、Docker 和 Kubernetes 等环境中如何使用 GPU。

跳转阅读原文:
GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU

1. 概述

仅以比较常见的 NVIDIA GPU 举例,系统为 Linux,对于其他厂家的 GPU 设备理论上流程都是一样的。


省流:

  • 对于裸机环境,只需要安装对应的 GPU Driver 以及 CUDA Toolkit 。

  • 对应 Docker 环境,需要额外安装 nvidia-container-toolkit 并配置 docker 使用 nvidia runtime。

  • 对应 k8s 环境,需要额外安装对应的 device-plugin 使得 kubelet 能够感知到节点上的 GPU 设备,以便 k8s 能够进行 GPU 管理。

注:一般在 k8s 中使用都会直接使用 gpu-operator 方式进行安装,本文主要为了搞清各个组件的作用,因此进行手动安装。

ps;下一篇分享下如何使用 gpu-operator 快速完成安装

2. 裸机环境

裸机中要使用上 GPU 需要安装以下组件:

  • GPU Driver
  • CUDA Toolkit

二者的关系如 NVIDIA 官网上的这个图所示:

components-of-cuda

GPU Driver 包括了 GPU 驱动和 CUDA 驱动,CUDA Toolkit 则包含了 CUDA Runtime。

GPU 作为一个 PCIE 设备,只要安装好之后,在系统中就可以通过 lspci 命令查看到,先确认机器上是否有 GPU:

root@test:~# lspci|grep NVIDIA
3b:00.0 3D controller: NVIDIA Corporation TU104GL [Tesla T4] (rev a1)
86:00.0 3D controller: NVIDIA Corporation TU104GL [Tesla T4] (rev a1)

可以看到,该设备有两张 Tesla T4 GPU。

安装驱动

首先到
NVIDIA 驱动下载
下载对应的显卡驱动:

search-gpu-driver

最终下载得到的是一个
.run
文件,例如
NVIDIA-Linux-x86_64-550.54.14.run

然后直接 sh 方式运行该文件即可

sh NVIDIA-Linux-x86_64-550.54.14.run

接下来会进入图形化界面,一路选择 yes / ok 就好

运行以下命令检查是否安装成功

nvidia-smi

如果出现显卡信息则是安装成功,就像这样:

root@test:~ nvidia-smi
Wed Jul 10 05:41:52 2024
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 535.161.08             Driver Version: 535.161.08   CUDA Version: 12.2     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  Tesla T4                       On  | 00000000:3B:00.0 Off |                    0 |
| N/A   51C    P0              29W /  70W |  12233MiB / 15360MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
|   1  Tesla T4                       On  | 00000000:86:00.0 Off |                    0 |
| N/A   49C    P0              30W /  70W |   6017MiB / 15360MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+

+---------------------------------------------------------------------------------------+
| Processes:                                                                            |
|  GPU   GI   CI        PID   Type   Process name                            GPU Memory |
|        ID   ID                                                             Usage      |
|=======================================================================================|
|   
+---------------------------------------------------------------------------------------+

至此,我们就安装好 GPU 驱动了,系统也能正常识别到 GPU。

这里显示的 CUDA 版本表示当前驱动最大支持的 CUDA 版本。

安装 CUDA Toolkit

对于深度学习程序,一般都要依赖
CUDA
环境,因此需要在机器上安装
CUDA Toolkit

也是到
NVIDIA CUDA Toolkit 下载
下载对应的安装包,选择操作系统和安装方式即可

download-cuda-toolkit

和安装驱动类似,也是一个 .run 文件

# 下载安装文件
wget https://developer.download.nvidia.com/compute/cuda/12.2.0/local_installers/cuda_12.2.0_535.54.03_linux.run

# 开始安装
sudo sh cuda_12.2.0_535.54.03_linux.run

注意:之前安装过驱动了,这里就不再安装驱动,仅安装 CUDA Toolkit 相关组件

安装完成后输出如下:

root@iZbp15lv2der847tlwkkd3Z:~# sudo sh cuda_12.2.0_535.54.03_linux.run
===========
= Summary =
===========

Driver:   Installed
Toolkit:  Installed in /usr/local/cuda-12.2/

Please make sure that
 -   PATH includes /usr/local/cuda-12.2/bin
 -   LD_LIBRARY_PATH includes /usr/local/cuda-12.2/lib64, or, add /usr/local/cuda-12.2/lib64 to /etc/ld.so.conf and run ldconfig as root

To uninstall the CUDA Toolkit, run cuda-uninstaller in /usr/local/cuda-12.2/bin
To uninstall the NVIDIA Driver, run nvidia-uninstall
Logfile is /var/log/cuda-installer.log

根据提示配置下 PATH

# 添加 CUDA 12.2 到 PATH
export PATH=/usr/local/cuda-12.2/bin:$PATH

# 添加 CUDA 12.2 的 lib64 到 LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/usr/local/cuda-12.2/lib64:$LD_LIBRARY_PATH

执行以下命令查看版本,确认安装成功

root@iZbp15lv2der847tlwkkd3Z:~# nvcc -V
nvcc: NVIDIA (R) Cuda compiler driver
Copyright (c) 2005-2023 NVIDIA Corporation
Built on Tue_Jun_13_19:16:58_PDT_2023
Cuda compilation tools, release 12.2, V12.2.91
Build cuda_12.2.r12.2/compiler.32965470_0

测试

我们使用一个简单的 Pytorch 程序来检测 GPU 和 CUDA 是否正常。

整个调用链大概是这样的:

cuda-call-flow

使用下面代码来测试能够正常使用,
check_cuda_pytorch.py
内容如下:

import torch

def check_cuda_with_pytorch():
    """检查 PyTorch CUDA 环境是否正常工作"""
    try:
        print("检查 PyTorch CUDA 环境:")
        if torch.cuda.is_available():
            print(f"CUDA 设备可用,当前 CUDA 版本是: {torch.version.cuda}")
            print(f"PyTorch 版本是: {torch.__version__}")
            print(f"检测到 {torch.cuda.device_count()} 个 CUDA 设备。")
            for i in range(torch.cuda.device_count()):
                print(f"设备 {i}: {torch.cuda.get_device_name(i)}")
                print(f"设备 {i} 的显存总量: {torch.cuda.get_device_properties(i).total_memory / (1024 ** 3):.2f} GB")
                print(f"设备 {i} 的显存当前使用量: {torch.cuda.memory_allocated(i) / (1024 ** 3):.2f} GB")
                print(f"设备 {i} 的显存最大使用量: {torch.cuda.memory_reserved(i) / (1024 ** 3):.2f} GB")
        else:
            print("CUDA 设备不可用。")
    except Exception as e:
        print(f"检查 PyTorch CUDA 环境时出现错误: {e}")

if __name__ == "__main__":
    check_cuda_with_pytorch()

先安装下 torch

pip install torch

运行一下

python3 check_cuda_pytorch.py

正常输出应该是这样的:

检查 PyTorch CUDA 环境:
CUDA 设备可用,当前 CUDA 版本是: 12.1
PyTorch 版本是: 2.3.0+cu121
检测到 1 个 CUDA 设备。
设备 0: Tesla T4
设备 0 的显存总量: 14.75 GB
设备 0 的显存当前使用量: 0.00 GB
设备 0 的显存最大使用量: 0.00 GB

3. Docker 环境

上一步中我们已经在裸机上安装了 GPU Driver,CUDA Toolkit 等工具,实现了在宿主机上使用 GPU。

现在希望在 Docker 容器中使用 GPU,需要怎么处理呢?

为了让 Docker 容器中也能使用 GPU,大致步骤如下:

  • 1)安装
    nvidia-container-toolkit
    组件
  • 2)
    docker
    配置使用
    nvidia-runtime
  • 3)启动容器时增加
    --gpu
    参数

安装 nvidia-container-toolkit

NVIDIA Container Toolkit 的主要作用是将 NVIDIA
GPU
设备挂载到容器中。

兼容生态系统中的任意容器运行时,docker、containerd、cri-o 等。

NVIDIA 官方安装文档:
nvidia-container-toolkit-install-guide

对于 Ubuntu 系统,安装命令如下:

# 1. Configure the production repository
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \
  && curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
    sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
    sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

# Optionally, configure the repository to use experimental packages 
sed -i -e '/experimental/ s/^#//g' /etc/apt/sources.list.d/nvidia-container-toolkit.list

# 2. Update the packages list from the repository
sudo apt-get update

# 3. Install the NVIDIA Container Toolkit packages
sudo apt-get install -y nvidia-container-toolkit

配置使用该 runtime

支持 Docker, Containerd, CRI-O, Podman 等 CRI。

具体见官方文档
container-toolkit#install-guide

这里以 Docker 为例进行配置:

旧版本需要手动在
/etc/docker/daemon.json
中增加配置,指定使用 nvidia 的 runtime。

    "runtimes": {
        "nvidia": {
            "args": [],
            "path": "nvidia-container-runtime"
        }
    }

新版 toolkit 带了一个
nvidia-ctk
工具,执行以下命令即可一键配置:

sudo nvidia-ctk runtime configure --runtime=docker

然后重启 Docker 即可

sudo systemctl restart docker

测试

安装
nvidia-container-toolkit
后,整个调用链如下:

nv-container-runtime-call-flow

调用链从 containerd --> runC 变成 containerd --> nvidia-container-runtime --> runC 。

然后 nvidia-container-runtime 在中间拦截了容器 spec,就可以把 gpu 相关配置添加进去,再传给 runC 的 spec 里面就包含 gpu 信息了。

Docker 环境中的 CUDA 调用大概是这样的:

cuda-call-in-container.png

从图中可以看到,CUDA Toolkit 跑到容器里了,因此宿主机上不需要再安装 CUDA Toolkit。

使用一个带 CUDA Toolkit 的镜像即可。

最后我们启动一个 Docker 容器进行测试,其中命令中增加
--gpu
参数来指定要分配给容器的 GPU。

--gpu
参数可选值:

  • --gpus all
    :表示将所有 GPU 都分配给该容器
  • --gpus "device=<id>[,<id>...]"
    :对于多 GPU 场景,可以通过 id 指定分配给容器的 GPU,例如 --gpu "device=0" 表示只分配 0 号 GPU 给该容器
    • GPU 编号则是通过
      nvidia-smi
      命令进行查看

这里我们直接使用一个带 cuda 的镜像来测试,启动该容器并执行
nvidia-smi
命令

docker run --rm --gpus all  nvidia/cuda:12.0.1-runtime-ubuntu22.04 nvidia-smi

正常情况下应该是可以打印出容器中的 GPU 信息的。

4. k8s 环境

更进一步,在 k8s 环境中使用 GPU,则需要在集群中部署以下组件:

  • gpu-device-plugin
    用于管理 GPU,device-plugin 以 DaemonSet 方式运行到集群各个节点,以感知节点上的 GPU 设备,从而让 k8s 能够对节点上的 GPU 设备进行管理。
  • gpu-exporter
    :用于监控 GPU

各组件关系如下图所示:

k8s-gpu-manual-instll-vs-gpu-operator

  • 左图为手动安装的场景,只需要在集群中安装 device-plugin 和 监控即可使用。

  • 右图为使用 gpu-operotar 安装场景,本篇暂时忽略

大致工作流程如下:

  • 每个节点的 kubelet 组件维护该节点的 GPU 设备状态(哪些已用,哪些未用)并定时报告给调度器,调度器知道每一个节点有多少张 GPU 卡可用。
  • 调度器为 pod 选择节点时,从符合条件的节点中选择一个节点。
  • 当 pod 调度到节点上后,kubelet 组件为 pod 分配 GPU 设备 ID,并将这些 ID 作为参数传递给 NVIDIA Device Plugin
  • NVIDIA Device Plugin 将分配给该 pod 的容器的 GPU 设备 ID 写入到容器的环境变量 NVIDIA_VISIBLE_DEVICES中,然后将信息返回给 kubelet。
  • kubelet 启动容器。
  • NVIDIA Container Toolkit 检测容器的 spec 中存在环境变量 NVIDIA_VISIBLE_DEVICES,然后根据环境变量的值将 GPU 设备挂载到容器中。

在 Docker 环境我们在启动容器时通过
--gpu
参数手动指定分配给容器的 GPU,k8s 环境则由 device-plugin 自行管理。

安装 device-plugin

device-plugin 一般由对应的 GPU 厂家提供,比如 NVIDIA 的
k8s-device-plugin

安装其实很简单,将对应的 yaml apply 到集群即可。

kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.15.0/deployments/static/nvidia-device-plugin.yml

就像这样

root@test:~# kgo get po -l app=nvidia-device-plugin-daemonset
NAME                                   READY   STATUS    RESTARTS   AGE
nvidia-device-plugin-daemonset-7nkjw   1/1     Running   0          10m

device-plugin 启动之后,会感知节点上的 GPU 设备并上报给 kubelet,最终由 kubelet 提交到 kube-apiserver。

因此我们可以在 Node 可分配资源中看到 GPU,就像这样:

root@test:~# k describe node test|grep Capacity -A7
Capacity:
  cpu:                48
  ephemeral-storage:  460364840Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             98260824Ki
  nvidia.com/gpu:     2
  pods:               110

可以看到,除了常见的 cpu、memory 之外,还有
nvidia.com/gpu
, 这个就是 GPU 资源,数量为 2 说明我们有两张 GPU。

安装 GPU 监控

除此之外,如果你需要监控集群 GPU 资源使用情况,你可能还需要安装
DCCM exporter
结合 Prometheus 输出 GPU 资源监控信息。

helm repo add gpu-helm-charts \
  https://nvidia.github.io/dcgm-exporter/helm-charts
  
 helm repo update
 
 
 helm install \
    --generate-name \
    gpu-helm-charts/dcgm-exporter

查看 metrics

curl -sL http://127.0.0.1:8080/metrics
# HELP DCGM_FI_DEV_SM_CLOCK SM clock frequency (in MHz).# TYPE DCGM_FI_DEV_SM_CLOCK gauge# HELP DCGM_FI_DEV_MEM_CLOCK Memory clock frequency (in MHz).# TYPE DCGM_FI_DEV_MEM_CLOCK gauge# HELP DCGM_FI_DEV_MEMORY_TEMP Memory temperature (in C).# TYPE DCGM_FI_DEV_MEMORY_TEMP gauge
...
DCGM_FI_DEV_SM_CLOCK{gpu="0", UUID="GPU-604ac76c-d9cf-fef3-62e9-d92044ab6e52",container="",namespace="",pod=""} 139
DCGM_FI_DEV_MEM_CLOCK{gpu="0", UUID="GPU-604ac76c-d9cf-fef3-62e9-d92044ab6e52",container="",namespace="",pod=""} 405
DCGM_FI_DEV_MEMORY_TEMP{gpu="0", UUID="GPU-604ac76c-d9cf-fef3-62e9-d92044ab6e52",container="",namespace="",pod=""} 9223372036854775794
...

测试

在 k8s 创建 Pod 要使用 GPU 资源很简单,和 cpu、memory 等常规资源一样,在 resource 中 申请即可。

比如,下面这个 yaml 里面我们就通过 resource.limits 申请了该 Pod 要使用 1 个 GPU。

apiVersion: v1
kind: Pod
metadata:
  name: gpu-pod
spec:
  restartPolicy: Never
  containers:
    - name: cuda-container
      image: nvcr.io/nvidia/k8s/cuda-sample:vectoradd-cuda10.2
      resources:
        limits:
          nvidia.com/gpu: 1 # requesting 1 GPU

这样 kueb-scheduler 在调度该 Pod 时就会考虑到这个情况,将其调度到有 GPU 资源的节点。

启动后,查看日志,正常应该会打印 测试通过的信息。

kubectl logs gpu-pod
[Vector addition of 50000 elements]
Copy input data from the host memory to the CUDA device
CUDA kernel launch with 196 blocks of 256 threads
Copy output data from the CUDA device to the host memory
Test PASSED
Done

至此,在 k8s 环境中也可以使用 GPU 了。


【Kubernetes 系列】
持续更新中,搜索公众号【
探索云原生
】订阅,阅读更多文章。


5. 小结

本文主要分享了在裸机、Docker 环境、k8s 环境中如何使用 GPU。

  • 对于裸机环境,只需要安装对应的 GPU Driver 即可。

  • 对应 Docker 环境,需要额外安装
    nvidia-container-toolkit
    并配置 docker 使用 nvidia runtime。

  • 对应 k8s 环境,需要额外安装对应的
    device-plugin
    使得 kubelet 能够感知到节点上的 GPU 设备,以便 k8s 能够进行 GPU 管理。

现在一般都是在 k8s 环境中使用,为了简化安装步骤, NVIDIA 也提供了
gpu-operator
来简化安装部署,后续分享一下如何使用
gpu-operator
来快速安装。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: Anytime Continual Learning for Open Vocabulary Classification

创新点


  • 在线训练时,每个批次由新训练样本和类别平衡的存储样本组成。
  • 在线学习每个标签的准确性,以有效对原始模型和调整后模型的预测进行加权。
  • 损失修改以支持“以上皆非”(不在预设标签内)的预测,这也使开放词汇训练更加稳定。
  • 中间层特征压缩,减少训练样本的存储并提高速度,同时对准确性的影响不大。

内容概述


论文提出了针对开放词汇图像分类的任意持续学习(
AnytimeCL
)方法,旨在突破批量训练和严格模型的限制,要求系统能够在任何时间预测任何一组标签,并在任何时间接收到一个或多个训练样本时高效地更新和改进。

AnytimeCL
基于一种动态加权机制,结合了部分微调的模型的预测与原始的模型的预测。当有新训练样本时,用存储的样本填充一个类别平衡的批次更新微调模型最后的
Transformer
块,然后更新对给定标签的调优和原始模型准确度的估计,最后根据它们对每个标签的预期准确度对调优模型和原始模型的预测进行加权。

此外,论文还提出了一种基于注意力加权的主成分分析(
PCA
)的训练特征压缩方法,这减少了存储和计算的需求,对模型准确度几乎没有影响。

AnytimeCL


论文旨在通过将微调模型与原始模型相结合来增强开放词汇图像分类器以学习目标任务。调优后的模型使用与原始模型相同的编码器,但包含一个可训练的解码器。

对于一幅图像
\(x\)
,调优模型和原始模型都生成所有候选标签的概率,分别表示为
\(P_t(y|x)\)

\(P_o(y|x)\)
,最终概率通过在线类别加权(
OCW
)进行加权:

\[\begin{equation}
\label{eq:our_weighting}
P(y|x) = \alpha_o(y) P_t(y|x) + \alpha_t(y) P_o(y|x),
\end{equation}
\]

在训练过程中,新样本被编码为中间特征(图像块的特征向量加上一个
CLS
标记),可以选择进行压缩并存储,以便在未来重复使用。

模型

  • 原始模型

原始模型是公开可用的
CLIP ViT
模型,该模型基于图像嵌入
\(e_{x}\)

CLS
标记)与文本嵌入
\(e_{y}\)
的点积,为图像
\(x\)
生成给定一组候选文本标签
\(\mathcal{Y}\)
的标签
\(y\)
的概率:

\[\begin{equation}
\label{eq:class_wise_probability}
P_o(y|x) = \frac{\exp(100 \cdot \cos(e_{x}, e_{y}))}{\sum_{y_k\in\mathcal{Y}} \exp(100 \cdot \cos(e_{x}, e_{y_k}))}.
\end{equation}
\]

  • 调优模型

调优模型仅调优最后的图像
Transformer
块,同时保持标签嵌入固定。这有助于特征与文本模态保持相关,并减少对接收标签的过拟合。

给定一个新样本,构造一个包含该样本的批次以及经过类平衡采样的存储训练样本。此外,使用一种正则化损失来帮助提高性能。如果真实标签不在候选标签中,那么每个候选标签都应该预测一个较低的分数。通过在候选集中添加一个“其他”选项来实现这一点,但由于“其他”没有具体的表现,仅用一个可学习的偏差项来对其建模。因此,训练调优模型的综合损失为:

\[\begin{equation}
\label{eq:final_loss}
\mathcal{L}(x, y, \mathcal{Y}) =\mathcal{L}_{\text{ce}}(x,y,\mathcal{Y} \cup \text{other}) + \beta \mathcal{L}_{\text{ce}}(x,\text{other},(\mathcal{Y} \cup \text{other}) \setminus y),
\end{equation}
\]

在线类别加权(
OCW

在更新之前使用每个训练样本,根据调优和原始预测来更新对其标签正确性的可能性估计,从而对给定标签正确的模型分配更高的权重。应用指数滑动平均(
EMA
)更新方法在线估计它们,符合随时持续学习的目标。假设
EMA
衰减设置为
\(\eta\)
(默认为
\(0.99\)
),当前步骤调优模型的估计准确性为:

\[\begin{equation}
c_t(y) = \eta \hat{c}_t(y) + (1 - \eta) \mathbb{1}[y_t(x)=y].
\end{equation}
\]

这里,
\(\hat{c}_t(y)\)
是前一步骤中标签
\(y\)
的估计准确性;
\(y_t(x)\)
表示调优模型对
\(x\)
的预测标签。由于指数滑动平均依赖于过去的值,将
\(c_t(y)\)
计算为前
\(\lfloor \frac{1}{1-\eta} \rfloor\)
个样本的平均准确性。
\(c_o(y)\)
也是以相同的方式更新的。

在获得
\(c_t(y)\)

\(c_o(y)\)
之后,两个模型的权重为:

\[\begin{equation}
\label{eq:final_alpha}
\alpha_t(y)= \frac{c_t(y)}{c_t(y) + c_o(y) + \epsilon}, \qquad \alpha_o(y)= 1 - \alpha_t(y).
\end{equation}
\]

这里,
\(\epsilon\)
是一个非常小的数(
1e-8
),用于防止除以零。对于调优模型未见过的标签,设置
\(\alpha_t(y)=0\)
,因此
\(\alpha_o(y)=1\)

存储的高效性与隐私性

模型的调优需要存储每个图像或者存储输入到调优部分的特征(或标记)。存储图像存在缺乏隐私和在空间和计算上低效的缺点,因为在训练中需要重新编码。存储特征可以缓解其中一些问题,但仍然使用大量内存或存储空间。

训练良好的网络学习到的数据高效表示往往难以压缩,如果尝试使用在某个数据集上训练的
VQ-VAE

PCA
(主成分分析)来压缩特征向量,将无法在不大幅损失训练性能的情况下实现任何有意义的压缩。然而,每幅图像中的特征包含许多冗余。因此,计算每幅图像中特征的
PCA
向量,并将这些向量与每个特征向量的系数一起存储。

此外,并非所有标记在预测中都是同等重要的。因此,可以训练一个逐图像的注意力加权
PCA
,通过每个标记与
CLS
标记之间的注意力加权。最后,可以通过存储每个向量及其系数的最小/最大浮点值,并将它们量化为
8
位或
16
位无符号整数来进一步压缩。通过以这种方式仅存储五个
PCA
向量及其系数,可以将
50

768
维标记(
\(7\times 7\)
patch 标记 +
CLS
标记)的存储从
153K
字节减少到
5K
字节,同时预测准确度的差异不到
1%

主要实验




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.