Skip to content

编程中的元工具

  • 调试器(debugger)
  • 包管理器(package manager)
  • 构建系统(build system)
  • 链路跟踪,性能分析等(tracer, profiling tools)
  • 张量-编译器(tensor compiler)

内容讲解

如何创建一个便利,强大,高效C++,Python编程的学习,开发环境

工具包括:

  • python package manager

    • import/module system
    • packaging solution
  • Docker(较新版本)+wsl2

    • docker ce
    • 有一个对应的docker镜像
  • conda

  • vscode

  • jupyter notebook(cling+clang)

  • gdb

  • cython

  • chatgpt

  • Docker创建开发容器

    • 基于wsl2的docker容器(可以使用GPU)
    • vscode + docker container
  • 使用Cling学习C++

    • Cling+MS,Learncpp教程的组合(语法+词法学习)
    • 讲解Cling如何导入库
    • 使用Cling探索bigInt
    • Cling结合探索CUDA
  • Cling背后的原理

    • 讲解LLVM,Clang的背景和工具意义
    • Cling软件架构,运行视图
  • 超强内存分析工具Memray

    • Memray基本使用(参考文档)
    • Memray分析推理框架工作时的堆内存
    • Memray工作原理
  • 用GDB调试Memray和其他Cython开发的软件

    • 确保编译的动态库带有调试符号
    • rbreak来打断点
    • 结合Python脚本来更好地分析Tracker中的代码
  • GDB从入门到精进

    • GDB基本用法
    • GDB结合Python,更强的调试工具
    • GDB多线程案例
    • PDB工具
  • TVM编译算子库 *

termux

作为提供tab生产力,我们需要好好研究下这个工具

机理

termux属于终端模拟器,使用"execve"系统调用并重定向标准输出的方法来工作。多数的安卓终端模拟器只具备非常少的权限,但在termux上,你能运行绝大多数的GNU软件。 首先,termux并不是虚拟机。所有提供的软件包都通过安卓NDK交叉编译,由于缺少完整的文件访问权限,termux并不能将文件放置在诸如/bin,/usr这样的路径,而是统一放在了/data/data/com.termux/files/usr,这个路径被称为"prefix",环境变量里的"$PREFIX"也是这个路径。注意这个路径不能被更改,因为:

  • 文件系统必须支持unix权限和特殊文件,如符号链接
  • 该前缀路径在termux项目中被硬编码了(没错,termux是开源的)

termux不符合FHS标准

这就是termux不使用Debian或者Ubuntu作为标准环境的原因了。Filesystem Hierarchy Standard是LINUX基金会制定的标准unix系统所需要满足的约定。因此termux上的应用都需要在特定环境重新编译一次。不过这个问题还是比较好解决的

是否需要root

如果你想做下面这些事,那你需要root:

  • 修改设备固件
  • 修改内核参数
  • 全部文件的读写权
  • 对硬件设备模块有直接访问权限

termux-desktop

修改密码

如果忘记了一开始设置的密码在,在termux中使用

shell
vncpasswd

可以修改相关密码

包管理器/构建系统

依赖管理

编程语言的module system

考虑自己去设计一个模块系统,会发现设计空间是很大的。模块可能是由文本组成的(C),也可能是运行时的代码(Python),不妨想象一个简化版本的模块系统,很多问题会变得简单:

  • 一个文件 = 一个模块
  • 文件的名字 = 模块的名字
  • 目录 = 项目

对于强类型,静态编译的语言,如C/C++,一般可以使用doxygen来导出相应的依赖关系,文件结构与逻辑结构的对应关系会更加明显直接,并且更容易处理和分析。

python的module系统

本模块会用一个demo项目pydemo来说明/演示python模块系统的一些细节

一些要点:

  • Python的import机制是一种延迟加载机制,只有在需要访问模块中对象的时,才会进行加载和执行
  • Python支持动态导入模块,使用importlib来动态加载模块
  • 搜索模块:Python会在当前目录,Python标准库目录,环境变量中PYTHONPATH指定的目录,第三方库目录,但是只要记住统合起来都会在"sys.path"中进行搜索
  • 编译模块:当搜索到了要导入的模块,Python解释器会将其编译为字节码文件(pyc或pyo文件),保存在__pycache__目录中

Python当中的模块对应到的是一个以py为后缀名的源码文件,Python还有一个语言特定的概念,包(Package),首先:包也是一个模块,包可以包含子模块或者是子包,包也是一个有__path__属性的模块,创建一个包的方式是,新建一个目录,然后在目录中创建__init__.py,当这个包被当做模块导入时,__init__.py中的内容便是会被导入的内容。

更准确的来说,是自动导入

比如导入pydemo之后,hubei被自动导入,但是guangdong却没有,关键就是在于根目录中的__init__.py

python
# 建议在交互式环境中测试一下
import pydemo

# output: <module 'pydemo.hubei' (<_frozen_importlib_external._NamespaceLoader object at 0x7fa22b15d150>)>
pydemo.hubei

# AttributeError: module 'pydemo' has no attribute 'guangdong'
pydemo.guangdong

当你import一个包的时候,包的子模块和子包并不会被导入,你需要在__init__.py中将这些模块导入进来(直接通过import xxx的方式无法导入)。

但是你可以通过"from package import submodule"(subpackage)的方式去显式地导入子模块

需要记住的事:当你导入一个模块,你会创建一个新的命名空间并把模块中的内容load进这个命名空间中,命名空间是基于Python的字典数据结构实现的,可以通过module.__dict__的方式来访问具体的对象

绝对导入和相对导入是一个需要引起注意的问题,比如你在外层目录(root-package)对子目录(sub-package)的模块进行引用时,如果子目录中的模块之间也存在引用关系,一不注意就容易引发"Module Not Found",使用相对导入可以一定程度上解决这个问题:

python
from . import xxx

当然,这种办法也会带来一些问题,比如当你去直接执行使用相对导入的文件时,会触发提醒“ImportError: attempted relative import with no known parent package”,这是因为使用了相对引入的文件的resolved规则在脚本和模块中是不一样的。

好好利用importlib,可以实现自己的导入机制。我们先来分析下importlib的关键用法

先来对importlib讲解的内容做个分类:

  • 实现定制化的import本身(更细致地说,实现finders, loaders, importer)
  • reload
  • import和__import__现在的实现本身(3.3更新内容之一)
  • 3.8新特性:metadata

有了importlib,你可以import everything

简单来说,importlib提供给用户修改import原语的能力

你甚至可以用这种方法高效地导入stackoverflow上的优秀代码

在相关机制的利用层面,我觉得最优秀的实践就是xonsh,一个将shell和python有机混合在一起的生产力工具,对于Python开发者而言,这应该就是最好用的shell了。 下面我们来仔细瞧瞧xonsh工具是如何使用以及如何利用importlib的

Xonsh 通过这张图大家就能知道,Xonsh是把shell和python这两种语言混在一起形成了一种新的超集语言(即xonsh),通过xonsh:

  • 你可以在命令行中定义Python的class,function,同时你可以利用shell到的应用加载器,环境变量等
  • 上面提到的能力意味着shell本身是"可编程的",你可以结合shell本身的功能和Python语法创造出自己的命令—这本身就是一种定制shell的过程
  • 配合cppyy这样的动态binding工具,最终可以将高性能的计算能力和高效开发环境结合,成为开发者的最佳toolchain

我们通过一些例子来演示:

那么,xonsh之下的魔法到底是什么呢?

我总结了一下,主要是这些:

  • 使用importlib实现了xonsh语言的Import系统
  • parser技术
  • subprocess stream的处理

xonsh的解析模式可以简单看成python-modesubprocess-mode的结合。具体的,我们可以看下:

# this will be in subproc-mode, because ls doesn't exist
>>> ls -l
total 0
-rw-rw-r-- 1 snail snail 0 Mar  8 15:46 xonsh
>>> # set ls and l variables to force python-mode
>>> ls = 44
>>> l = 2
>>> ls -l
42
>>> # deleting ls will return us to subproc-mode
>>> del ls
>>> ls -l
total 0
-rw-rw-r-- 1 snail snail 0 Mar  8 15:46 xonsh

reload函数可以完成插件化的模块设计

使用metadata的一些典例:

python
from importlib import metadata
from pprint import pprint

# 将元信息的列表展示出来,元信息本身是dict的数据结构
list(metadata.metadata("pandas"))

# 列举项目的模块路径
pprint([p for p in metadata.files("pandas") if p.suffix == ".so"])
pprint([p for p in metadata.files("pandas") if p.suffix == ".pyx"])

Python项目结构的典例。我希望通过下面这些知名项目来介绍典型的文件组织结构:

标准库

  • asyncio

第三方库

  • FastAPI(纯Python项目,Web框架)
  • memray
  • annoy(高效算法的精简binding)
  • thinc(层次分明的深度学习框架)

asyncio

asyncio的文件组织是:

asyncio/
  __init__.py
  xx.py

其中,init文件的大致内容如下:

python
# 将有__all__变量的子模块的所有内容导出
from .base_events import *
from .coroutines import *
from .events import *

# 将all导出
__all__ = (base_events.__all__ + coroutines.__all__ + ...)

if sys.platform == 'win32':
  from .windows_events import *
  __all__ += windows_events.__all__
else:
  from .unix_events import *
  __all__ += unix_events.__all__

去分析源码,能看到的是,一些没有暴露接口的子模块,如log.py,一般是处在整个依赖关系中的最底层。__all__指明了那些会被导出的符号,但是请注意,这种声明,只会影响到那些通过from <module> import *的场景,即是说那些没有被包含的符号也可以通过from xx import xx的方式来导出。

FastAPI

c/c++的include系统

distributing/setup

安装Python软件包

我们大多使用pip来安装pypi上的软件包,软件包以两种形式存在:

  • source distributions
  • wheels 如果pypi上两种资源都存在,pip会优先选择wheel,wheel是一种预编译的发行包,它会包含编译好的二进制扩展(其实wheels设计之初就是为了更好的屏蔽二进制扩展后工具链的复杂性)。 wheels是在PEP 427中被提出的,下面的内容是对关键信息的提炼。

首先,wheels文件实质上是一种ZIP格式的以.whl作为后缀的文件扩展。wheel安装最大的优点是快速,简单且暴力,安装过程可以细化为两个步骤,Unpack以及Spread: 下面以distribution-1.0-py32-none-any.whl的安装过程为例:

  • Unpack
    • 解析dist-info中的WHEEL文件
    • 首先验证wheel版本和安装器是否兼容。
    • 如果 Root-IS-Purelib == 'true',将文档解压至(purelib)site-packages
    • 上述条件不满足,解压至(platlib)site-packages
  • spread
    • 被解压的文件中包含distribution-1.0-info和(如果有的话)distribution-1.0.data

wheel文件中包含的内容有:

  • WHEEL中指定的purelib和platlib路径下的
  • {distribution}-{version}.dist-info/包含元数据
    • METADATA:
    • RECORD:
    • WHEEL:
      • WHEEL-Version
      • Generator: 构造这个文档的工具
      • Root-Is-Purelib:略
  • ``

关于purelibplatlib,不同平台的路径不一样,对于unix类平台下的路径,纯PY文件一般是在/usr/lib/pythonX.Y/site(或者是dist)-packages,平台相关的文件则是在/usr/lib64/pythonX.Y/site-packages下,但是要注意,so文件一般也都是在/usr/lib/pythonX.Y/site-packages中

打包Python

首先这一部分的内容大家可以在两个官方文档下获取相关的资源:

学习打包Python应用,我们主要就是要学:

  • 打包Python应用所必须得工具有哪些
  • 一个项目要能够打包并进行分发,得考虑哪些关键问题
  • 如果项目中使用到了C/C++作为子项目或者是项目的一部分,该如何打包

Python应用已经渗透进了不同领域。

python项目的打包流程方案的提出是通过PEPs(Python Enhancement Proposals)达成的,而方案的实现则是交给了PyPA(Python Packaging Authority)。相关的PEPs有这些:

  • PEP 427 描述了wheels文件是如何被打包的
  • PEP 440 描述了版本号是如何被解析的
  • PEP 508 描述了依赖关系是如何被处理的
  • PEP 517 描述了"build backend"是如何工作的
  • PEP 518 确立了"build system"的内容
  • PEP 621 描述了项目元信息应该包含哪些内容
  • PEP 660 描述了可编辑安装(editable installs)是如何使用的

一个标准的Python项目一定需要包含的内容是:

  • pyproject.toml
  • 指定的build system

让我们了解一下通用Python项目的打包方法。 首先,在最外层目录创建setup.py以及setup.cfg文件,这两个文件的最简化表示如下:接下来在该目录下,使用

shell
python -m pip install -e .

从PEP-621开始,python社区统一选择pyproject.toml作为项目元信息的标准,setuptools也支持了这一标准,那么问题是两者的依赖关系究竟是怎么样的。简单来说,是setuptools依赖于pyproject.toml,具体的,跟setuptools相关的主要是[build-system]和[project]两项信息。

python项目的标准配置文件是pyproject.toml,这份配置中,使用者需要在[build-system]表中指定构建工具,常用的构建工具包括:flit,hatch,pdm,poetry,setuptools, 我们至少得会使用setuptools,这项工具被大多数的大型项目用来作为默认的构建工具(build backend)。

展示一份标准的pyproject.toml:

toml
[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"

[project]
name = "example_package_YOUR_USERNAME_HERE"
authors = [
  {name = "xxx", email = "xxx@xx.edu"},
]
description = "My package description"
readme = "README.rst"
requires-python = ">=3.7"
license = {text = "BSD-3-Clause"}
# 摘自FastAPI
dependencies = [
  "starlette>=0.27.0,<0.28.0",
  "pydantic>=1.7.4,!=1.8,!=1.8.1,!=2.0.0,!=2.0.1,!=2.1.0,<3.0.0",
  "typing-extensions>=4.5.0",
  "anyio>=3.7.1,<4.0.0",
]

紧接着考虑写一份完整的pyproject.toml,你需要考虑的内容包括这些(由于不同构建工具对应的字段头不一样,我们这里用setuptools来做例子):

  • 有哪些package,它们在哪 -> [tool.setuptools.packages]

我们进入到实践环节,首先最常见的,当我们有一个Python应用(application),我们要如何打包,按照我自己的经验,主要是下面几部:

  • 项目文件结构规整化,注意这些目录/文件
    • <project_name> - 存放源码的地方,源码所体现的模块的逻辑结构不再陈述,复习请回到前文的import系统
    • .gitignore, LICENSE.txt, README.md - 项目附带文件
    • docs - 存放相关文档的路径
    • config - 项目配置文件
    • setup.py, requirements.txt - 安装脚本
  • 处理依赖项
    • 如果有扩展代码,则需要完成对应的extension(setup.py)
    • 对于依赖的第三方库,可以使用pipreqs这样的工具自动resolve项目的依赖项,从而生成requirements.txt
    • 完成setup.py,包括一些项目元信息的配置
  • 测试setup.py

一篇关于处理依赖关系的比较好的答案: stackoverflow-49689880 重点看下pip-compile的内容

setup.py的背后

对于开发者而言,一定需要了解最通用的安装方案。setuptools强大的点可以概括为如下几点:

  • 使用广泛:Setuptools是Pythondistutils包的增强版,distutils是Cpython内置的官方构建工具,现在在逐步淘汰中
  • 自动地搜索所有相关文件,并将它们包含到项目树中
  • 支持Cython,可以搜索并找出pyx文件
  • 支持开发者模式,可以直接修改源代码调试
  • 对PEP420 find_namespace_packages()的全面支持

我们先从如何写一个稍微有些复杂项目的setup.py开始,介绍python的打包过程

下面我以两个混合编程的项目(cython)作为例子,来讲解一个项目的打包和分发

首先是pandas,pandas是一个典型的cython加速库,从该项目的"setup.py",我们能学到不少东西(一些tricks),我们来梳理该文件的重要内容(提交54497中):

  • 首先项目使用的是setuptools的build_ext方法, 然后对调用的方法简单地进行了封装,从而在构建过程中能够render_template(前提是安装了cython)
  • 接着我们看pandas项目中对单个Extension的封装:变量声明在547行,单个obj的装载在434 -> 581行,结构如下:
python
# depends单独列举(头文件或_pxi_dep),sources是单个pyx文件的路径,include在_libs/include下,
Extension(
  f"pandas.{name}",
  sources, depends, include,
  include = ["pandas/_libs/include", numpy.get_include()],
  extra_compile_args = [],
  extra_link_args = [],
)
  • cmdclass中关键的"build_ext"字段,是继承于build_ext的一个class,会在build_ext之前,执行check_cython_extensions方法来确保pyx源代码确实存在:
python
def check_cython_extensions(self, extensions):
    for ext in extensions:
        for src in ext.sources:
            if not os.path.exists(src):
                print(f"{ext.name}: -> [{ext.sources}]")
                raise Exception(
                    f"""Cython-generated file '{src}' not found.
            Cython is required to compile pandas from a development branch.
            Please install Cython or download a release package of pandas.
            """
                )
  • setup方法显然是从setuptools处获取的

另外我们再来看,我的示例项目hpds的源码结构以及native模块的引用位置:

hpds
  acclerate # 依赖pybind11和cuda
  concurrent # 依赖C++的std algorithm-cython
  wrapper # 依赖.c中的函数-cython

项目中后两项是依赖于cython的,因此setup.py可以参照pandas项目的构建逻辑来编写。而pybind11,我们可以从官方文档里找到答案(当然cython也一样):https://pybind11.readthedocs.io/en/stable/compiling.html,

这里,我们发现一个问题:pybind自带的Pybind11Extension并不支持.cu文件的编译(毕竟编译cuda代码使用的其实是nvcc编译器),因此,这一步,无论用什么方法,似乎都无法逃脱cmake

CMake/make系统

之前已经简要介绍过了CMake的作用。我们以unix下的安装过程举例,一般安转一个软件包,安装的内容包括:

  • 可执行文件(bin)
  • 头文件(相当于对外暴露的接口,include)
  • 库文件(lib)
  • cmake文件(share/package) 安装的内容普遍是安装到"prefix"下(通常是/usr/local)

构建系统

构建系统是和语言强绑定,我们主要看C++,python这两个语言的一些项目和常规方法

开发容器管理

一个可随时迁移,并同步的开发环境是非常重要的。

相关工具集

  • chroot
  • vagrant
  • container
  • lxc rootfs
  • Nix

理念

可以看看vagrant作者对于隔离环境的看法:看法 实际上vagrant不应该和docker本身比较,而是和Boot2Docker(一个可以运行Docker的最小操作系统)

Portable Development-environment

容器VS虚机

工具应该具备的特性:

  • flexibility
  • reproducible results
  • 能够支持多种镜像格式:
    • Docker Images
    • CHOS Images
    • vmware Images

前菜:通过bocker了解docker

了解btrfs

简言之是"Better FS",suse和fedora的默认文件系统,具备这些特性:

  • 创建快照
  • copy on write:与其覆写,不如拷贝
  • subvolumes(有点像分区,partitions++)
  • checksums:可以保证数据完整性

缺点:

  • 不要用RAID(和标准的RAID不一样)

深入介绍容器技术

了解容器技术我认为最终要的几个关键词:

  • Namespaces
  • 联合文件系统
  • oci runtime
  • linux control group
  • 内核能力

Namespaces

Docker使用linux namespace技术来实现容器间的资源隔离

Namespacesyscall隔离内容
PIDCLONE_NEWPID隔离用户的进程
UTSCLONE_NEWUTS使每个容器有独立的hostname和domain name,使其在网络上可以被视为独立节点
IPCCLONE_NEWIPC保证容器间的进程交互,信号量,消息队列,共享内存的相互隔离
networkCLONE_NEWNET实现网络隔离,每个net namespace有独立的network devices,ip addresses, ip routing tables,/proc/net目录
MountCLONE_NEWNS每个namespace的容器在/proc/mounts的信息中只包含namespace的mount point
UserCLONE_NEWUSER允许每个容器有不同的user和group id

一个问题:要区分不同的shell环境,关键是哪一个namespace的隔离在起作用(系统变量)

联合文件系统

我们会讲到:

  • 什么是unionFS
  • 实践:挂载overlayFS到实验路径进行讲解
  • 实践:OCI image和unions的关系,探索文件系统上的机制

unionFS是一种分层,轻量级并且高性能的文件系统,它对文件系统的修改作为一次次提交来层层叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。我们知道Docker镜像可以通过分层来继承,不同的Docker容器可以共享基础文件系统层,再加上自己独有的改动层,大大提高存储效率,因此该技术算得上是容器的核心技术之一。Docker支持的联合文件系统包括: aufs,btrfs,vfs,devicemapper, overlay2

联合文件系统的核心特性:

  • copy-on-write

要查看当前docker所使用的文件系统可以:

shell
docker info | grep 'Storage Driver'

# podman
podman info | grep 'graphDriverName'

# 为后面使用podman查看overlay做好准备
sudo ln -s /home/xiao-sa/.local/share/containers/storage/ /var/lib/podman

下面我们以OverlayFS为例,讲解联合文件系统,首先文件系统的底层是只读的,而上层是可读写的,而展示出的层通常被称为merged/union view; overlay-1

下面来实际演示一次overlayFS的实际表现

shell
# 创建demo目录
...
# mount
sudo mount overlay -t overlay -o lowerdir=demo/lower, upperdir=demo/upper, workdir=demo/work/ demo/merged/

看详细内容,请查看https://ravichaganti.com/blog/2022-10-18-understanding-container-images-the-fundamentals/

探索podman overlay
shell
# 为后面使用podman查看overlay做好准备
sudo ln -s /home/xiao-sa/.local/share/containers/storage/ /var/lib/podman

# 查看pytorch/llvm:12.0.0下overlay的内容
podman image inspect pytorch/llvm:12.0.0 | jq -r '.[0] | {Data: .GraphDriver.Data}'

# 在文件系统中去查看
# 找一下对应关系
ll /var/lib/podman/overlay

# 新建一个container
podman run -d --name test_1 pytorch/llvm:12.0.0 sleep 100000

# 查看容器的存储层级
podman container inspect test_1 | jq -r '.[0] | {Data: .GraphDriver.Data}'

# 修改容器文件
podman exec test_1 touch /hello.txt

原本的输出为:

json
{
  "Data": {
    "LowerDir": "/home/xiao-sa/.local/share/containers/storage/overlay/397004f98f9973770f8647404f684002f0d55eeba0a13ca7e107abb1f69110a4/diff:/home/xiao-sa/.local/share/containers/storage/overlay/8e012198eea15b2554b07014081c85fec4967a1b9cc4b65bd9a4bce3ae1c0c88/diff",
    "UpperDir": "/home/xiao-sa/.local/share/containers/storage/overlay/9da6771510c20a3565d232b18d14d473ffcb7b56887255decfbb1f2128f8a1da/diff",
    "WorkDir": "/home/xiao-sa/.local/share/containers/storage/overlay/9da6771510c20a3565d232b18d14d473ffcb7b56887255decfbb1f2128f8a1da/work"
  }
}
json
{
  "Data": {
    "LowerDir": "/home/xiao-sa/.local/share/containers/storage/overlay/9da6771510c20a3565d232b18d14d473ffcb7b56887255decfbb1f2128f8a1da/diff:/home/xiao-sa/.local/share/containers/storage/overlay/397004f98f9973770f8647404f684002f0d55eeba0a13ca7e107abb1f69110a4/diff:/home/xiao-sa/.local/share/containers/storage/overlay/8e012198eea15b2554b07014081c85fec4967a1b9cc4b65bd9a4bce3ae1c0c88/diff",
    "MergedDir": "/home/xiao-sa/.local/share/containers/storage/overlay/3069b91a2c5d6780c6b99441d10cd7eaa6f24d805bfeca000e92e1e31faf11f7/merged",
    "UpperDir": "/home/xiao-sa/.local/share/containers/storage/overlay/3069b91a2c5d6780c6b99441d10cd7eaa6f24d805bfeca000e92e1e31faf11f7/diff",
    "WorkDir": "/home/xiao-sa/.local/share/containers/storage/overlay/3069b91a2c5d6780c6b99441d10cd7eaa6f24d805bfeca000e92e1e31faf11f7/work"
  }
}

下面是一个大致的依赖关系(注意hash值是错误的,但是只是为了说明overlay的关系)

img

图中的layer本身是镜像的layer,所以是只读的,不可以修改的。

Docker容器存在的一个很大问题是:镜像体积在多次提交后变得过于膨胀,该怎么去面对和解决这个问题呢? 首先,为什么一般我们在交互式窗口中使用yum这类的包管理器仅仅是装了一个小工具或者库之后,体积的膨胀却超出了软件体积本身呢?

大家先得掌握一个命令:

shell
docker history <image-id>

这个命令能够看到镜像的提交历史,其中SIZE那一列的增长最终都会作用到镜像本身,因为在某一个layer提交之后的layer,其删除的磁盘空间实际仍在之前的layer被保留着。

那如何解决这个问题?我们给自己设置一个目标:制作一个满足自己需求的最小容器,其实有几种方法可以实现这个目标:

  • from scratch/alpine
  • 使用"-v"挂载到本地的某个rootfs
  • distroless-docker

这个主题本身就有不小的研究价值,我们放到最小化容器镜像来研究

runc & OCI runtime spec

我们先来了解OCI(open container initiative)

The Open Container Initiative is an open governance structure for the express purpose of creating open industry standards around container formats and runtimes.

链接

该组织是在2015年被Docker公司创立的,OCI当前包含三项标准:

  • the Runtime Specification (runtime-spec)
  • the Image Specification (image-spec)
  • the Distribution Specification (distribution-spec).

第一项标准指出了如何运行一个解压好的文件系统包(filesystem bundle),一个OCI的实现,可以下载一个OCI镜像,并将它解压为一个OCI文件系统包并运行。其中,docker公司实现的runc就是一个标准的OCI runtime实现,可以作为runtime的一个标杆。

想了解具体有哪些实现,可以看这里:https://github.com/opencontainers/runtime-spec/blob/main/implementations.md

综上述,"container"概念本身其实和Docker并无直接联系,只是Docker算是应用最广泛的容器管理工具,实际上现阶段,像podman这样兴起的工具也开始扩建自己的应用市场,培养起自己的生态了,除了docker本身所支持的功能,podman还有一些看起来更优秀的特性,这个我们等会说。

容器运行时(container runtime)是实际进行环境隔离的容器运行引擎,runc

更高性能的crun

crun链接

crun是一个使用C语言编写的更高性能/更少内存占用的runc,下面介绍runc-cli本身所支持的功能(crun也基本是这些指令):

  • create创建容器。一旦创建了容器环境,运行时就与容器进程分离。必须依次使用start来启动容器。删除容器的定义。

  • exec执行正在运行的容器中的命令。

  • list列出已知的容器。

  • kill向容器init进程发送指定的信号。如果没有指定信号,则使用SIGTERM。

  • ps显示容器中运行的进程。

  • run创建并立即启动一个容器。

  • spec生成配置文件。

  • start启动之前创建的容器。一个容器不能多次启动。

  • state输出容器的状态。

  • pause暂停容器中的所有进程。

  • resume恢复容器中的进程。

  • update更新容器资源约束。

  • checkpoint使用CRIU保存当前运行的容器

  • restore从检查点恢复容器

想要看更加细致的options清单,看这

下面我们讲讲,源码安装:

apt-get install autoconf
git clone https://github.com/containers/crun.git
./autogen.sh

OCI image spec

这里,我们会讲到:

  • 什么是image spec,spec当中的重点

  • 如何自己从头构建一个container image

image的layer

容器的镜像都是由多个"layer"组成的,每一层可以新增,移除,或是改动文件;和git概念中的commit很类似(docker也有commit这样的功能)。layer是可以节省网络带宽和存储空间的:如果多个镜像共享layer,多个容器共同的从一个layer上去读一个文件,系统将只读和缓存一次(overlay2/aufs drivers)。这意味如果我们要节省磁盘IO和内存,我们可以尽力地让在一个节点上所运行的多个容器共享尽可能多的layer。

这种情况下使用动态链接的可执行程序是最节省内存开销的。

虚拟机镜像和docker镜像的结构差异

首先对于Docker而言,容器和镜像的差异仅是一个写入层(write layer),Docker镜像的组成实际是多个layer叠加在一个base layer上。这意味着:

  • 如果我们使用基于同一操作系统的多个不同镜像,它们会共用同一个base layer
  • Docker镜像本身不存在状态,所有对于容器的更改会写到上层的layer中

上面说的这些特性,都是虚机镜像所没有的,接下里让我们了解一下Disk image file formats:磁盘镜像

磁盘镜像是磁盘结构和常规数据的快照。一般来说,磁盘镜像是磁盘上每个扇区的逐位副本,但现在通常的结构是只复制已分配的数据来减少其体积。压缩和重复数据删除的技术被广泛用于减小镜像体积。Virtual disk images(比如VHD和VMDK)通常用于云计算,ISO镜像则通常用于数字取证

最小化容器镜像

大体上,我们本节涉及的主题,包括:

  • 构造较小体积生产容器的基本技巧
  • 实践:
    • 构建一个cuda benchmark的较小镜像
    • 构建镜像的check-list,pipeline
  • 构造最小镜像的高阶技巧

在介绍一些更高阶的技巧前,我们先来回顾docker本身所支持的一些技巧:

  • multi-stage builds

首先是我们运行一个程序,像编译器这样的toolchain我们实际是不需要的,我们只需要runtime-dep,即程序运行时所依赖的文件,下面是一个典例

shell
FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c
FROM ubuntu
COPY --from=mybuildstage hello .
CMD ["./hello"]
  • from scratch

结合多阶段构建,我们需要使用一个不包含无关依赖的最小基础镜像(基础镜像,指的是存放可执行文件的镜像),下面是一个典例:

shell
FROM golang
COPY hello.go .
RUN go build hello.go

FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]

注意最后一句非常关键,不能写成CMD ./hello,因为这种写法默认使用的是bin/sh,但是我们没有这个启动器,另一方面,如果基础镜像是scratch,我们将无法进入容器(docker exec/kubectl exec),当然,除了scratch,我们也可以用Alpine版本的镜像,搭载了最小化的操作系统环境。

上面的例子演示的镜像体积最终结果如下:

  • Single-stage build using the golang image: 805 MB
  • Multi-stage build using golang and ubuntu: 66.2 MB
  • Multi-stage build using golang and alpine: 7.6 MB
  • Multi-stage build using golang and scratch: 2 MB

再来,了解一些高阶的技术:

大家可以试着装下alpine的docker images,不到5MB的磁盘占用

shell
docker pull gliderlabs/alpine:3.4

alpine也是一个linux发行版,但是体积比其他发行版要小许多,究其原因,分析如下:

  • 只含有最小的必备依赖(可使用apk info查看有哪些)
  • /var/lib/rpm
  • systemd
  • gconv(字符集)
  • coreutils,glibc(使用的musl,这个过于小众),bash都简化了
  • 没有预装python

那为什么有些场景还是不推荐使用Alpine呢?如果使用Alpine镜像作为运行C代码的基础镜像,同样存在一个问题,Alpine使用的标准C库并不是glibc,而是musl,这个库比起glic来说更加的轻量,一个比较关键的问题在于,musl的ABI接口和glibc的并不兼容,因此一个二进制文件想要动态链接musl并运行,需要被重新编译,这个过程其实可以通过带有alpine的toolchain镜像结合多步编译来解决。

shell
FROM golang:alpine
COPY hello.go .
RUN go build hello.go

FROM alpine
COPY --from=0 /go/hello .
CMD ["./hello"]

如果是C程序的编译,由于并不存在gcc:alpine,我们必须在alpine中安装C编译器:

shell
FROM alpine
RUN apk add build-base
COPY hello.c .
RUN gcc -o hello hello.c

FROM alpine
COPY --from=0 hello .
CMD ["./hello"]

What behind Distroless

  • bazel

首先来看下,what behind docker build

小小的题外话:upx

UPX(ultimate packer for exe)是一种可执行打包器,可以用来压缩可执行文件的压缩器。通常来说,UPX可以减小程序和DLL的50-70%的体积。

原理相关内容:

  • 依赖于NRV压缩库
  • /src/stub包含了不同可执行格式的"解压桩",stub由汇编编写,并被“编译”成C头文件
  • UPX的压缩算法是UCL
  • UCL不需要为解压分配额外的内存,

想要更细致地了解UPX,可以看这两个链接:

AI-容器

shared gpu

其他容器软件

podman

Shifter containers for HPC

项目软件架构

性能损失

shifter是专为HPC应用设计的镜像引擎,它由几部分组成:

  • 为应用创建执行环境的工具集
  • 镜像网管系统,将镜像重新打包以适应squashfs

Docker4c

一个专门为C/C++开发设计的容器环境: blog 里面包含的内容包括:

  • compiler
  • cmake
  • Conan

使用lxc文件系统

使用docker容器存在一些问题:

  • 容器的根文件系统使用overlayfs,添加文件或者是删除文件,文件系统占用空间都不会减少
  • 容器删除后,未提交的修改都会被丢弃,但不断提交会使得镜像体积膨胀

可以从Linux Container社区下载根文件系统tar包,解压系统到宿主机,使用hello world镜像创建容器,将该文件系统挂载到容器

调试器

自己开发ptrace based debugger

shell

下面介绍一些对于我工作,生活特别有意义的一些命令行工具(主要是unix类的) 首先会介绍一些存储,网络相关的通用命令,解决磁盘以及网络管理的硬需求

devbox(Nix)

官方说法“Reproducible development environments wherever you go“,这个概念接近于我前面提到的Portable dev env

'可重现'对于开发人员来说非常重要,不管是复现问题还是搭建开发环境再或者是部署软件栈复杂的服务(比如AI相关的)。显然,系统上安装什么软件应该是包管理器要做的工作,但是现实生活中,我们使用apt,yum或是什么其他的,总是会导致一样的命令在两套系统上得到不一样的结果(这个跟linux发行版有很大的联系)。

Nix可以帮助我们客服难关。本质上,我们要处理的所有环境相关的内容,都可以称之为“依赖”,在Nix的世界里,这些都是通过Nixpkgs来管理的。

大体来说,你需要知道的几个概念:

  • Nixlang:一种函数式DSL,擅长通过已有文件派生新的文件,是一门lazily evaluated的语言
  • Nixpkgs: 世界上最大的,更新最频繁的软件分发系统,使用Nix语言编写
  • NixOS:一个可以完全采用声明式配置的Linux分发版本,充分利用了Nix语言和Nixpkgs

事实上nix的诞生还是比较早的,大概在20年前,可以在Initial version of nix找到:github.com/NixoS/nix/commit/75d788 nix-workflow

Nix软件栈:

Nixos-stack2.png

有效的Nix学习资源

这些资源会指导你,帮助你达到:

  • 学会使用nix构建自己的application以及library
  • 查阅nix, nix-shell, nix-env, nix-store等cli的关键workflow
  • 掌握nix workflow的工作原理
  • 使用nix构建标准容器镜像
总览资源

Nix的学习材料实在太多了,这里推荐我认为最有效的:

细分资源
  • 讲述nix-packaging的回答:unix.stackexchange.com/questions/717168
  • 阐述nix的purity的帖子:reddit.com/r/NixOS/comments/xd0b7y/comment/io9vjcy/?utm_source=reddit&utm_medium=web2x&context=3
  • 讲述nix-packaging的回答:nixos.wiki/wiki/Nixpkgs/Create_and_debug_packages
  • 特定语言的构建帮助(最好用的):ryantm.github.io/nixpkgs/#preface
  • 边讲边纠错的packaging教程:nix.dev/tutorials/packaging-existing-software
  • 创建一个开发shell环境的教程:nix.dev/guides/recipes/sharing-dependencies#sharing-dependencies
卸载Nix

速览Nixlang

Nixlang的文档:nix.dev/tutorials/nix-language#reading-nix-language

内容非常丰富

  • Nix代码完全由Nix表达式组成(嗯哼,scala)
  • 任意Nix表达式的演算结果为一个值
  • 使用nix repl进入nix的交互式运算环境
  • nix-instantiate --eval xx.nix可以验算nix文件
  • nix中的值可以是原生数据类型,数组,attribute sets或者是函数
  • attrubute sets代表键值对
  • 使用<>作为查找路径,其值依赖于builtins.nixpath
  • <nixpkgs>指向文件系统的路径
  • 除了表达式,还有杂质,杂质的存在目的只有一个:从文件系统中读取文件作为构建的输入
  • Derivations是nix的核心特性:
    • nix本身就是用来描述派生的
    • nix通过运行派生来产出结果
    • derivation本身是一个内置函数

Nixpkgs介绍

nixpkgs是一个巨大的软件库,我们先来讨论下常规的package manager如dpkg,rpm会遇到的问题:如果foo-1.0把程序安装在了usr/bin/foo,你没有其他方法在不重命名的情况下安装并运行foo-1.1(新的,旧的二进制文件)。为了解决这个问题,debian提出了alternatives系统。但这实际上也存在缺陷,比如你无法同时运行两个实例。所以我们又有了容器,这在一定程度上解决了问题,但从开发者的视角看,也存在问题:两个环境是隔离的,你怎么把两个软件栈混合在一起呢?

Nix认为这些问题是它处理的最好的地方,Nix管理系统的核心是/nix/store,Nix的派生存储在/nix/store/<hash-name>,Nix也有数据库,在安装的时候会存储在/nix/var/nix/db,这是一个sqlite数据库,用来跟踪derivations的关系,其中schema的关系也很简单:

其中一个表是将自增的整形映射到存储路径,还有一个依赖关系的表,将关系的依赖存储起来。

我们还需要留意profile

shell
ls -l ~/.nix-profile

nix-profile

如图我电脑上的nix版本是nix-2.1.3,我们在上述路径下看到的是Nix软件包自身的derivation,profile指向的是用户默认的版本,比如profile-4-link

nix-bin

进入到bin目录下我们能看到二进制文件们都指向到了nix-store中存储nix本身的地方

安装了nix之后,~/.nix-profile/bin会被放到$PATH的首位置,从而系统原本的应用会被Nix接管

可以使用nix命令查看profile历史

shell
nix profile history

# 下面为输出:
a-experimental-features nix-command
Version 1 (2023-11-15):
  nix: -> 2.18.1

Version 2 (2023-11-17) <- 1:
  sqlite: -> 3.43.1-bin

Version 3 (2023-11-17) <- 2:
  hello: -> 2.12.1

如果需要将nix的profile切换回第二版,我们可以使用

shell
# 切换到上一个版本
nix-env --rollback

# 查看generations(并得知当前的generations)
nix-env --list-generations

# 切换到具体的generation
nix-env --switch-generation x
其他常用命令
# 列举出显式安装的package
nix-env -q

# 查看可以获取的package
nix-env -qaP <pkg>

在nix世界中,所谓package指的是一个Nix derivation:完成对某个项目的安装,Packages有一些标准化的attributes以及文件输出布局

nix-env是我们用来管理环境会用到的最多的应用,我们可以使用他来进行:

  • 安装/删除/更新 软件
  • 通过profile进行版本管理

下面我们来用nix命令探索一番:

shell
# 查看nix提供的应用有哪些
ls -al ~/.nix-profile/bin

# 安装hello
nix-env -i hello

image-20231117222521561

另外,使用nix-store,我们可以获取软件包之间的关系甚至是操纵它们的关系:

shell
# 推荐使用hello做实验,最简单的一个package
# 获取dervation的依赖以及依赖dervation的包
nix-store -q --references <dervation>
nix-store -q --referrers <dervation>

# 树形结构打印
nix -store -q --tree <dervation>
nixPkgs的工作机制

nixpkgs本身是一个软件集合,同时是一大堆的nix expression的集合。我按照自己的想法把nixpkgs的组成部分划分成了如下几个:

  • cache.nixos.org: nixpkg的binary cache

使用Nix进行Packaging

derivation函数接受的集合至少需要包括三种attributes:

  • name
  • system
  • builder

什么是.drv文件呢?

简单的例子请参考:nixos.org/guides/nix-pills/working-derivation

使用nix来完成packaging的过程,更加人性化的指导,可以从nixos.wiki/wiki/Nixpkgs/Create_and_debug_packages处获取,简单说说里面的内容。

对于许多开发者而言,最想要的无非是在任意系统上能够快速地,方便地重现开发环境,或者说是shell环境,为此我们需要做的其实是packaging 以及 distrubuting,我们以一个案例来介绍这件事情在nix世界中如何完成。

如同我们上面介绍的,我们使用nix工具主要是在和derivation打交道,在分发这件事上同样如此,我们要准备自己的derivation。

为此我们需要准备几个工具:

  • stdenv.mkDerivation:该特殊函数会为你准备一个功能齐全的构建环境;另外你也可以在基础环境上扩展依赖;同样地,该函数会为你准备让你进行./configure && make的builder
  • nix-shell:在你构造运行环境的过程中,得有一个调试/验证功能是否通过的过程,为此我们需要一个类似python的venv一样的功能,在nix生态中,这就是nix-shell
  • callPackage:能够将package的集合中的默认值传递给构建derivation的函数
nix-shell的魔法

nix-shell会将derivation的依赖都构建出来,并启动一个derivation声明的环境变量的交互式shell环境,因此nix-shell本身就是用于重现环境的工具,如果path选项并未被提供,nix-shell会从工作路径中读取shell.nix文件,如果该文件不存在,则会读default.nix文件。如果路径是基于http或者https的,则会从对应路径下载一个tarball,当然tarball的最高层级必须包含default.nix

如果derivation定义了shellHook变量,nix会在运行了source $stdenv/setup之后,运行该定义中的命令。同时我们需要重点介绍下wrapProgram,该功能的详细文档可以去nixos.org/manual/nixpkgs/stable/#ssec-stdenv-functions下查阅。

演示的内容

我们要做的事情,简单来说就是:

  • 分发一个开箱即用的有一定复杂性的软件包(package)
  • 分发一个有相同特点的应用(application)

需要一定的复杂性,我觉得Python的软件栈可以cover的住这样的需求(一个使用了混合编程技术的软件栈)。同时构建完成的标准是:能够在其他环境上快速地构建出一样的shell环境

从nixpkgs中学习

我们从nixpkgs中找到几个有参考意义的derviation:

一个问题是,这些表达式的调用处在哪里?在pkgs/top-level/all-packages.nix,接着可以通过nix-build -A <pkg>验证build过程是否能顺利完成,以及通过nix-env -f . -iA <pkg>将pkg安装到profile中,最后,通过PR提交到官方仓

一个C/C++项目的构建

参照nix.dev上提供的教程,我们先来构建一个简单的计算类型的application: sort-bench

我们需要cover:

  • 编译
  • 安装:将关键的executable file保存起来

详情请到./envs/sort-bench目录下进行实践:

了解Python相关的nix表达式

在进入到实践之前,我们先来简单了解下nixpkgs给python语言提供的可以直接使用的表达式。首先是Python解释器的表达式可以在pkgs/development/interpreter/python路径下找到。

考虑到大多数人都是以使用Cpython为主,我们就来分析一下该目录下的项目构成(想要通过nixpkgs构建自己的包系统的各位可以考虑多去参考一下大粒度项目的default.nix,因为一般一个稳定的nix表达式是经过时间考验的并且考虑到很多平台因素的结果)。首先是cpython/default.nix,构建解释器的方法是stdenv.mkDerivation,这是因为Cpython解释器属于C语言项目,那default.nix必定有build c code的过程。

我按照自己的思路和认为是重点的部分将default.nix区分为这几块:

  • python编译参数,列举几个比较重要的:
    • pythonAttr
    • passthruFun
  • assert部分:注意到有许多断言语言,且都和reproducibleBuild相关,以stripBtyecode为例,代表着如果要启用可重现构建,则必须启用字节码剥离
  • 构建依赖项和配置:
    • sysconfigdataHook:Python有一个特殊的模块:sysconfigdata,该模块是Cpython编译期间产生的,
  • 构建命令:
    • preConfigure
    • postInstall

这里有个细节:可重现构建基本一定会影响到效率,这是因为优化的flag会影响到可重现性,具体可以参考社区的一个帖子:discourse.nixos.org/t/why-is-the-nix-compiled-python-slower/18717/9,这种时候就可以依赖override来进行项目定制化的改造了:

一段示例表达式:

nix
let
  pkgs = import nixpkgs { };
  python38Optimized = pkgs.python38.override {
    enableOptimizations = true;
    reproducibleBuild = false;
    self = python38Optimized
  };
  pyPkgs = python38Optimized.pkgs;
in
python38Optimized.pkgs.buildPythonPackage rec {
  name = "python-example";
  inherit src;
  propagatedBuildInputs = with pyPkgs; [
    numpy
  ];
  nativeBuildInputs = with pyPkgs; pkgs.lib.optionals (pkgs.lib.inNixShell) [ipython];
}

记录一些Python和nix的相关项目:

  • on-nix/python
  • dream2nix
  • pynixify
  • nixfied-ai/flake
Python-library构建模板

我们主要依赖buildPythonPackage函数来进行构建,其实现路径在pkgs/development/interpreterrs/python/mk-python-derivation.nix

对于Python library的构建,我们需要准备几个nix文件:

  • pkgs.nix:这是指向内容地址的Nixpkgs
  • default.nix:当前package的derviation(使用nix-build)
  • shell.nix:将会import default.nix,并覆写开发专用的依赖项
  • release.nix:同样import default.nix,并且生成多个release targets,其中就包括容器镜像
shell, application构建模板

我们一定需要一个可以调试的shell环境,为此,我准备了一个shell.nix模板

nix
with import <nixpkgs> {};
with pkgs.python3Packages;

buildPythonPackage rec {
    name = "mypackage";
    src = ./path/to/source;
    propagatedBuildInputs = [ pytest numpy pkgs.libsndfile ];
}
使用nix构建包管理系统

到这里,是时候对nix构建出来的世界进行回顾和总结,并设想在自己的项目中,如何最大化地利用这一个工具。

我们先来研究一下Python和Nix结合的实践,从pynixify开始,首先安装方式,pynixify的安装方法是:nix-env -if default.nix,因此我们来分析一下pynixify的文件结构以及default.nix中的内容:

nix
# 待补充

我自认为的pynixify的核心设计原则:

  • 扩展pkgs的python能力:自动构建pypi上有但nixpkgs上没有的python包,

存储相关的工具

lsblk

以树形结构列出所有块设备,输出信息中各列的意义介绍:

  • NAME:设备名称,包括磁盘,分区,RAID,LVM
  • MAJ:MIN:设备的主次设备号,用于唯一标识设备
  • RM:是否为可移动设备,0表示不可移动,1表示可移动
  • SIZE:设备大小,以字节为单位
  • RO:是否为只读设备,0表示可读写,1表示只读
  • TYPE:类型。
    • disk 磁盘
    • part 分区
    • raid RAID
    • lvm LVM(逻辑卷管理器)
  • MOUNTPOINT:设备的挂载点,如果未挂载则为空

mount

e2fsck

用于检查和修复ext4文件系统的命令。

fdisk/parted

fdisk是Linux下的磁盘分区工具,可以用来创建,删除,调整MBR分区,下面列出常用方法: *

hwinfo

GDB介绍

GDB是GNU组织发布的UNIX平台下的程序调试工具,同样基于ptrace系统调用,使用监控程序检查和改变被追踪者的内存及寄存器。

gdb最重要的几个用途:

  • 入侵程序runtime,读写数据
  • 主要支持大多数编译型的语言

几个问题:

  • 我的GDB workflow

  • GDB和Python到底什么关系?

  • GDB可以调试Python程序吗?咋做?

  • GDB对于Python开发者以及混编开发者最大的作用

gdbinit

  • gdbinit是什么
  • 使用gdbinit可以做到什么

GDB调试方法

  • 直接启动进程调试 -> gdb [program]
  • 调试core文件 -> gdb [program] [core] : 拿到core查看出问题的调用栈,寄存器信息
  • gdb -p pid

常用命令

最好用命令:gdb -tui或依赖vscode以及gdb-gui等工具

  • 设置断点

    • b <src:line>

    • b func_name

    • 删除断点:delete n(n代表第n个断点)

  • 查看堆栈信息

    • 打印最内层:bt n
    • 查看所有线程:thread apply all bt
  • 线程

    • 转到某线程: thread thread_number
  • 栈帧

    • frame n
  • 执行下一步命令(单步)

    • next

gdb奇巧

  • gdb --args

使用Python扩展gdb

case1:深入Python进程,做些恶作剧
shell
./configure --with-python
# 在gdb进程中嵌入Cpython解释器
# 这个命令+python debug symbols

GDB的Python扩展能够给到我们什么:

  • 在GDB中访问高层级的CPython解释器信息
  • 在Python代码中访问GDB命令
  • 定制化GDB commands

常用的访问Python信息的GDB命令:

  • python [command]
  • py-list
  • py-bt
  • py-up/py-down
  • pylocals
  • py-print
shell
gdb -p <pid> -x gdb_commands.py
# 让gdb内的Cpython读取py_ext,获取新命令
python
import gdb
# gdb.execute()是王道API,可以直接指定gdb去做任何事,有点像subprocess.Popen
gdb.execute()
# 那么,gdb里的 `call <expr>`又有什么讲究呢?
# 1.首先gdb会去查表,查符号表,找到所有函数的符号
# 2.查表会找到函数的对应地址,然后强制进程跳转到对应内存地址执行相应指令
# 3.configure with Cpython允许你call <C API>

一些重要的C API

  • PyRun_SimpleString("<any python string>")
  • PyGILState_Ensure()
  • PyGILState_Release()
case2:进一步封装C++对象,inspect them

语言binding

binding一般是由C/C++/Rust等语言暴露给脚本语言的接口集合,在C++语言中,一般就是给C++的类/函数创建一层封装,从而在脚本语言中能够import这些对象。

思考:混合编程的意义是什么

在《API design for C++》这本书中,作者的描述如下:

  • 跨平台:脚本语言解释执行,从而它们的一般有中间形式
  • 快速开发:重新编译/开发其实是很耗费时间和精力的,从REPL这种软件形式的诞生你也可以窥探一二
  • 写更少的代码:在一个工具的工作生命周期中,通常工具的一些行为并非性能敏感的部分,用脚本语言实现这部分内容可以写更少的代码(比如命令行工具)
  • 为行业专家提供支持:如pytorch, Pixar

Python生态圈中的binding工具

  • cython
  • cppyy(很特殊,需要拿出来单独讨论)
  • pybind11
  • PyO3
  • ctype

binding的自动生成工具:

  • binder

接下来会逐一解释这些binding工具中的典型,以及它们背后的原理

cython

pybind11 & Binder

clif

pyO3

pyO3是一个相对成熟的rust-binding库,大家一定会好奇这个工具是怎么work的。从reddit上摘录一个最高分的回答,大家可以去看看原问题:Can someone help me understand PyO3?i'm not sure hwo it works.

首先Python的核心实现-Cpython将python核心功能暴露到了一个名为libpython的库中,PyO3在这个库外部用安全的RUST API封装了起来。当你在Rust侧调用python时,实际上你在Rust进程中链接了libpython,有点像在浏览器中运行js。

cppyy

cppyy的特殊性体现在它本身是一个动态绑定的系统,他可以在运行过程中编译并运行c++代码,pypy原生就集成了cppyy(听起来就很像是通过cling来完成的,我只能说你想的很对)。 github上的cppyy仓实际是一个系统的前端部分,要将整个工具运行起来,我们需要先安装中间层以及后端的内容。

项目整体的结构长这样:

              (A) _cppyy (PyPy)
          /                        \
(1) cppyy                            (3) cppyy-backend -- (4) cppyy-cling
          \                        /
            (2) CPyCppyy (CPython)

REPL

主题:C++的REPL实现

C++语言解释层

详情请关注:https://compiler-research.org/libinterop/

这里将一些个人认为重要的观点谈一下。

Python在科学计算以及范·机器学习领域内有很强大的生态,一个非常关键的因素就是其可交互性,结合其本身的胶水特性,Python在进行各种research的工作中可谓是神器。

依赖Pybind11这样的构筑在Python C-API上的C++ API,Python得以将native语言的高性能优势发挥出来。然而,这样的做法存在局限性,首先是这种binding方法属于静态的binding,效率上存在缺陷,另一方面是“trampoline functions”的问题:

在 Pybind11 和其他类似工具中,要在 C++ 类型和 Python 类型之间建立映射关系,通常需要使用类似于 trampoline 的方法。简单来说,trampoline 函数是一个中间层,负责将 Python 对象的调用转发到底层的 C++ 对象上。在 Python 中,当子类继承自父类时,通常需要将子类的对象向上转换为父类的对象,因此需要在 C++ 和 Python 中使用不同的 trampoline 函数来处理继承关系。这些 trampoline 函数通常会在绑定代码中生成,以确保 C++ 对象的函数在 Python 中的调用能够正确地处理继承语义。

然而,在某些情况下,这些 trampoline 函数可能需要重复定义和生成,以满足不同的继承关系。这些被称为 "redundant trampoline functions"(冗余的 trampoline 函数),因为它们可能会在代码中重复出现,导致代码变得冗长和难以维护。举个例子,如果一个 C++ 类型被多个 Python 类型继承,那么需要为每个 Python 类型都定义一个 trampoline 函数,这可能导致许多相似的函数定义。这是一种常见的问题,可能会导致代码的不必要的膨胀和维护成本的增加。

另一方面,很多人认为动态绑定的效率一定比静态绑定要低,这实际上是个误区。 没错,反例就是cppyy。

cling介绍

C++是难以学习的一门语言,为了更好的掌握它,一些工具可能会帮到你很多。比如C++ REPL cling

相比于C++,Python会更容易学习,实际上这不单是语言复杂度的问题,我认为还有一个很原因是Python对于新人来说,可交互性会更好,自带REPL运行模式的解释器,而且这是一种常用模式,比如我们所熟知的Jupyter也是基于这种使用习惯所诞生的产品。REPL使我们可以更频繁地与Python语言本身交互,进行数据的探索,对于科学计算,数据分析类的应用而言,这至关重要,另一方面,这种探索也能快速提高一个人对于语法以及词法的熟练程度。

静态Binding生成工具Binder

binder是一个自动生成C++11项目的binding项目的项目。

原理介绍

参考文章https://blog.llvm.org/posts/2020-11-30-interactive-cpp-with-cling/

Cling支持完整的C++特性。包括模板,lambdas,虚拟继承。而Cling是在Clang和LLVM编译器架构上开发的一款应用。

image-20230330004813608

  • 该工具通过交互式提示符或者一些REPL接口处理增量输入
  • 将clang发送给其依赖的clang库进行编译,clang编译输入内容,将其转化为AST(②,③)
  • 特殊情况下,AST要做进一步的转换(④)
  • 将AST转化为LLVM-IR(⑤),LLVM-IR再次提交给JIT编译架构,去运行特定函数(⑥)
  • 最终,生成的机器码(⑦)被提交给对应的处理硬件

C++本身的设计并没有考虑交互性,全局scope的声明执行,报告执行结果,实体的重定义是体现REPL用户友好性的最重要的三个特性。

常用操作

学习C++语法知识
jupyter notebook打开

启动对应的容器命令

shell
docker run -it --network host --gpus all /bin/bash

启动jupyter notebook

shell
setsid jupyter notebook --port=8888 --no-browser --ip=0.0.0.0 --allow-root
写CUDA
调试动态库文件

以dlib的matrix对象为例,演示如何调试库中的对象

  • 以debug模式编译dlib,并且生成动态库二进制文件
像Python一样写use case
查看数据结构,数据类型

Profiling tools

libunwind

libunwind是内存分析工具memray所依赖的核心组件之一,

compiler

loopy介绍

AI assistance

chat-gpt

下面给出一些图例来说明chat-gpt在不同场景下对于开发的帮助(基于gpt-3.5)。

学习编程语言的特性

知识在于积累