本文永久链接https://tonybai.com/2026/09/11/go-simd-kills-cgo-turbopfor-avx512

大家好,我是Tony Bai。

一段跑了7年的C语言压缩库,被Go 1.27的实验性SIMD包干掉了,AI还顺手甩出了一个2倍加速的“外挂”。

【导读】:今年8月,Debian Code Search的作者Michael Stapelberg做成了一件憋了很多年的事——删掉项目里最后一行cgo代码。这背后的功臣,是Go 1.26引入、在Go 1.27上愈发成熟的实验性simd/archsimd包。他用纯Go重写了跑了7年的C语言TurboPFor压缩库,不仅性能反超,还借助AVX512的“位置汇编计数”黑科技把速度又翻了一倍——而这个关键优化,是AI编程助手Claude发现的。

【文章要点】

  • 告别七年 cgo 心结:Debian Code Search 作者使用 Go 1.26/1.27 引入的实验性 simd/archsimd 包,将服役 7 年的核心 TurboPFor 整数压缩库彻底重写为纯 Go,删除了项目中最后一行 cgo 代码;
  • 纯标量优化的前置红利:在未动用真正的 SIMD 指令前,通过复用结构体缓冲区消除内存分配(+11%)、利用泛型数组将位宽“焊死”为编译期常量消除分支(+40%~64%),让纯 Go 基础性能直接达到 C 语言库的 76% 以上;
  • AVX2 垂直布局斩获 3 倍加速:利用 256 位向量寄存器同时并行处理 8 个 32 位无符号整数,彻底消除了标量处理的内层循环,直接带来基准性能的 3 倍跃升;
  • AI 顺手送出 2 倍“黑科技外挂”:AI 助手 Claude Fable 5 准确识别出编码瓶颈在于位宽直方图扫描,并提出了基于 GF2P8AFFINEQBVPOPCNTB 的“位置汇编计数(Positional Popcount)”正交变换优化,将单值处理指令数从 12 条暴降至 1.5 条,在追平 cgo 的基础上再次翻倍;
  • 硬件性能极限逼近:重写后的 Go 编码器最终反超了历史 cgo 版本,运行时指令吞吐量达到了每周期 7 条指令(7 IPC,极其逼近现代 CPU 的 8 IPC 理论上限),为纯 Go 在高性能系统编程领域的替代铺平了道路。


一个困扰了工程师多年的心结

Debian Code Search(简称DCS)是一个搜索引擎,用来在整个Debian发行版的开源代码里做字面量或正则表达式检索。它的核心是一套倒排索引:从“词项”映射到“包含该词项的文档”,而文档通常用整数ID表示,所以索引本质上是海量的整数列表。

这些整数列表需要被极高效地压缩和解码,否则索引既存不下,查询也快不起来。DCS采用的是业界知名的TurboPFor整数压缩格式,多年来一直依赖C语言的powturbo/TurboPFor库,通过cgo接入Go项目。

问题是,DCS从一开始就被设计成一个纯Go项目,作者一直不喜欢代码库里混进C代码——但SIMD(单指令多数据)级别的性能,过去在Go里几乎无法企及。这个心结,一压就是7年。

在Go 1.26之前,Go开发者想用SIMD只有三条路

在实验性simd/archsimd包出现之前,Go开发者如果想榨干CPU的向量指令性能,基本只有三个选择,而且每一个都不轻松:

  1. 手写Go汇编:只适合非常小的函数,例如标准库bytes.IndexByte就是用手写汇编(含AVX2)实现的,但代码可读性和可维护性都很差;
  2. 用工具生成汇编:比如Michael McLoughlin开发的Avo,标准库crypto/internal/fips140/sha256的AVX2实现就是这么来的。这比纯手写汇编“高级”一些,但本质上仍然是在跟汇编打交道;
  3. 用cgo调用C库:让gcc或clang去编译真正的SIMD代码,DCS过去7年一直走的就是这条路。

三条路各有各的坑:汇编难写难维护,代码生成器门槛高,cgo则意味着要接受一门“外来语言”长期驻留在纯Go项目里,交叉编译、构建流程都会因此变复杂。

Go 1.26(2026年2月发布)带来了转机——官方发布说明写道:

Go 1.26引入了实验性的simd/archsimd包,可以通过在构建时设置环境变量GOEXPERIMENT=simd来启用。该包提供对特定架构SIMD操作的访问,目前支持amd64架构,提供128位、256位和512位的向量类型(如Int8x16Float64x8),以及Int8x16.Add这样的操作。目前API尚未稳定。

到了Go 1.27,这套实验性能力已经足够扎实,可以支撑一个真正的生产项目去验证——这正是本文要讲的故事的技术底座。

下面这张图梳理了Go在SIMD这件事上走过的路:

起点:先写一个“能跑就行”的朴素版本

在动手优化之前,作者先梳理了DCS对整数编解码的三种使用场景——局部索引(新软件包入库时的增量编码)、索引合并(把海量局部索引合并成少数几个大索引)、查询解码(用户搜索时高并发解码)。据此他设计了一套简洁的API:

package pforenc

type BlockEncoder struct {
    // 复用的临时缓冲区放在这里
}

// EncodeBlock 把不超过256个uint32编码进dest(一个TurboPFor块)
func (*BlockEncoder) EncodeBlock(dest []byte, vals []uint32) []byte {}

// EncodeN 循环调用EncodeBlock
func (*BlockEncoder) EncodeN(dest []byte, vals []uint32) []byte {}

type StreamEncoder struct {
    be   BlockEncoder
    vals [256]uint32
    // 临时缓冲区
}

// 满了就需要调用EncodeBlock
func (*StreamEncoder) Add(val uint32) (full bool)

func (*StreamEncoder) EncodeBlock() []byte {
    if se.n == 0 {
        return nil // 多余的调用变成空操作
    }
    // …
}

最初的编码器实现简单到近乎“偷懒”:所有数值都按32位定长、小端序写入,每256个值加一个字节的块头:

func (be *BlockEncoder) EncodeBlock(dest []byte, vals []uint32) []byte {
    const bitWidth = 32
    dest = append(dest, bitWidth)
    for _, val := range vals {
        dest = binary.LittleEndian.AppendUint32(dest, val)
    }
    return dest
}

这当然是个压缩率极差的实现,但它是个可用的起点。接下来作者逐一实现了TurboPFor真正的几种块类型:bitpacking(按最小必要位宽定长压缩)、bitpacking with exceptions(用位图标记少量“超标”的异常值)、bitpacking with VB exceptions(用变长字节编码异常值,适合异常值很少的场景)、constant(整块只存一个值,适合全零或全相同的块)。

做完这一步,一个关键发现浮出水面:编码器真正的开销大头,是扫描输入值来决定用哪种块类型,而不是编码本身——这个发现,为后面那个“2倍加速彩蛋”埋下了伏笔。

此时,朴素版Go编码器的性能已经达到C版本的76%。作者原本觉得,到这一步其实就可以收工了。但他还是决定,看看到底能把Go推多远。

开局:把测量工具先搭好

在正式动手优化前,作者花了不少篇幅讲“怎么量得准”,这一段对任何做性能优化的工程师都很有参考价值。

设置正确的微架构等级(GOAMD64)。Go从1.18开始支持通过GOAMD64指定编译目标的x86-64微架构等级:

等级 包含指令集
v1(默认) 所有x86-64处理器都支持的基线指令
v2 v1 + CMPXCHG16B、POPCNT、SSE4.2等
v3 v2 + AVX、AVX2、BMI1/2、LZCNT、FMA等
v4 v3 + AVX512F/BW/CD/DQ/VL

作者建议2026年一般项目至少设置GOAMD64=v3,这样像bits.OnesCount8这类函数会被编译成POPCNT硬件指令而不是查表实现。DCS这个项目由于服务器和开发机都是较新的AMD Zen 4/Zen 5,直接用上了GOAMD64=v4(需要AVX512支持)。

基准测试与benchstat。作者用Go内置的testing包写基准测试,并用golang.org/x/perf/cmd/benchstat工具对比不同commit的性能变化,同时用taskset把测试进程锁定在固定核心上,避免核心迁移带来的噪声。

perf看CPU硬件计数器。光看pprof知道“哪里慢”还不够,作者进一步用Linux的perf工具读取CPU硬件性能计数器(比如分支预测失败次数),来搞清楚“为什么慢”,这是Intel提出的“自顶向下分析法”(Top-down Analysis)的典型应用。

第一波优化:不上SIMD,纯标量也能挤出不少性能

1. PGO:一次意外的“负优化”,牵出了CPU对齐的暗坑

Profile-Guided Optimization(PGO,画像引导优化)是Go 1.21起正式可用的特性:先采集一份CPU性能画像,再喂给编译器,让它更激进地内联函数、做条件去虚化。

按理说PGO应该是稳赚不赔的优化,但作者开启PGO后,性能反而下降了13%!排查后发现,PGO会让编译器给热循环打上PCALIGNMAX(64,31)的对齐标记——按照AMD官方的Zen 5优化指南,把热循环对齐到64字节缓存行边界通常是好事。

但这次运气不好:对齐之后,一对被宏融合的CMPQ+JGE指令恰好落在了32字节边界上,而Go编译器为了修复Intel的SKX102勘误(这两条指令绝不能跨越或落在32字节边界),会插入额外的NOP指令来避让——这些额外指令拖慢了本就是“指令派发瓶颈”的循环。

这是一个很好的提醒:编译器优化并非单调递增,理解它的副作用同样重要。所幸后续的优化commit改变了代码结构,这个“不走运的排列”并未在整个优化过程中反复出现。

2. 减少内存分配

原教学版解码器每次需要临时缓冲区时都直接make()分配,这类由变量长度决定、无法在编译期确定大小的分配,会实实在在地走一次runtime.makeslice调用。

作者把这类临时缓冲区改为结构体里预先分配好的固定数组字段(复用而非每次新建),在DCS的debian-mix基准上把速度从773 Mval/s提升到858 Mval/s,提升约11%。这个改动同时也让基准测试更稳定,因为垃圾回收器被请出了热路径。

3. 泛型位宽特化:让编译器把循环“焊死”成常量代码

这是这一波优化里最有意思的一招。TurboPFor的bitpacking核心函数,其控制流实际上只取决于两件事:输入值的数量要打包的位宽。如果这两者在编译期就已知,编译器就能把循环完全展开,生成几乎没有分支、只剩位运算和内存读写的“最优机器码”。

先把输入数量固定为32,手动展开循环;再借助Go泛型,用数组类型自带长度信息这一特性,把位宽也变成编译期常量:

type bitWidthT interface {
    [1]byte | [2]byte | [3]byte | [4]byte | [5]byte |
    [6]byte | [7]byte | [8]byte | [9]byte | [10]byte |
    // … 一直到 [32]byte
    [31]byte | [32]byte
}

func bitpack32Unrolled[T bitWidthT](dest []byte, vals *[32]uint32) {
    var zero T
    bitWidth := len(zero)                  // 编译期已知
    dest = dest[: 4*bitWidth : 4*bitWidth] // 容量也编译期已知
    mask := uint32(1<<bitWidth - 1)
    var acc uint64
    var have, pos int
    // 循环体在这里手动展开,vals[0]..vals[31]逐一处理
    acc |= uint64(vals[0]&mask) << have
    have += bitWidth
    if have >= 32 {
        binary.LittleEndian.PutUint32(dest[pos:pos+4], uint32(acc))
        pos += 4
        acc >>= 32
        have -= 32
    }
    // …
}

编译器会为每一种位宽实例化出一份独立的函数,生成的机器码几乎是无分支的、只由确定操作数的移位和位运算构成。效果非常明显——在处理256值以下的“余块”(remainder block)时,各类场景普遍取得**40%到64%**的加速:

vals=bitpacking-bw1     751.2  →  1120.5 Mval/s  (+49.15%)
vals=bitpacking-bw2     716.8  →  1176.0 Mval/s  (+64.07%)
vals=bitpacking-bw7     700.0  →  1078.5 Mval/s  (+54.08%)
vals=debian-mix         559.5  →   783.8 Mval/s  (+40.09%)

代价是二进制体积略有增大(.text段约增加20KB),作者认为这个代价完全值得。

第二波优化:真正祭出SIMD

即便不上真正的SIMD指令,只是把处理步长做大(比如用bits.OnesCount64一次数64位而不是逐位判断),也能带来提升。但要触及数量级的性能差距,还得靠向量指令。

1. SIMD构建标签:运行时探测 + 编译期开关双保险

simd/archsimd包需要考虑一个现实问题:不是所有CPU都支持AVX2/AVX512,程序需要在运行时做特性探测,并在旧CPU上优雅回退到标量实现。典型的三文件模式是这样的:

// constant_nosimd.go —— 不满足SIMD条件时的兜底实现
//go:build !goexperiment.simd || !amd64

package pfordec

func fillConstant(output []uint32, val uint32) {
    fillConstantScalar(output, val)
}
// constant_amd64.go —— amd64架构下的SIMD实现,运行时探测AVX2
//go:build goexperiment.simd && amd64

package pfordec

import "simd/archsimd"

var hasAVX2 = archsimd.X86.AVX2()

func fillConstant(output []uint32, val uint32) {
    if !hasAVX2 {
        fillConstantScalar(output, val)
        return
    }
    val8 := archsimd.BroadcastUint32x8(val)
    i := 0
    for ; i+8 <= len(output); i += 8 {
        val8.StoreArray((*[8]uint32)(output[i : i+8]))
    }
    fillConstantScalar(output[i:], val) // 处理剩下不足8个的元素
}

GOAMD64设置为v3或更高时,甚至可以直接把hasAVX2定为编译期常量true,省掉运行时判断的开销。DCS最终针对编码器和解码器的不同函数,分别用到了AVX2、AVX512,乃至AVX512+VBMI+GFNI+BITALG这些更细粒度的指令子集组合。

2. 256值垂直布局:AVX2直接带来3倍加速

TurboPFor有一种专为SIMD设计的“垂直布局”(256 uint32 vertical layout),同时处理8个uint32小端值,充分利用AVX寄存器宽度。

常规bitpacking的位布局示意图:

256值垂直布局(SIMD bitpacking)的解码顺序示意图:

标量版本用8个uint64累加器,逐一处理8个值:

func bitunpack256v32(input []byte, dest []uint32, bitWidth int) (read int) {
    mask := uint64(1)<<bitWidth - 1
    var bits uint
    var acc [8]uint64 // 累加器:当前位+剩余位
    for op := 0; op < len(dest); {
        if bits < uint(bitWidth) {
            for i := range 8 {
                acc[i] |= uint64(binary.LittleEndian.Uint32(input)) << bits
                input = input[4:]
            }
            bits += 32
        }
        for i := range 8 {
            dest[op] = uint32(acc[i] & mask)
            op++
            acc[i] >>= bitWidth
        }
        bits -= uint(bitWidth)
    }
    return len(dest)
}

SIMD版本同样处理8个值一组,但没有for i := range 8这层循环——8个值被“压”进了一个向量寄存器里并行处理。由于AVX2寄存器一次只能装8个uint32(装不下uint64),累加器被拆成rest8cur8两个Uint32x8

func bitunpack256v32(fullinput []byte, fulldest []uint32, bitWidth int) (read int) {
    dest := fulldest[:256]
    n := 32 * int(bitWidth)
    input := fullinput[:n]
    mask8 := archsimd.BroadcastUint32x8(uint32(1)<<bitWidth - 1)
    bitWidth8 := archsimd.BroadcastUint32x8(uint32(bitWidth))
    var bits uint
    pos := 0
    var rest8, cur8 archsimd.Uint32x8
    for op := 0; op < 256; op += 8 {
        if bits < uint(bitWidth) {
            next := archsimd.LoadUint8x32(input[pos : pos+32]).ReshapeToUint32s()
            pos += 32
            cur8 = rest8.Or(next.ShiftAllLeft(uint64(bits)))
            rest8 = next.ShiftAllRight(uint64(uint(bitWidth) - bits))
            bits += 32
        } else {
            cur8 = rest8
            rest8 = rest8.ShiftRight(bitWidth8)
        }
        cur8.And(mask8).Store(dest[op : op+8])
        bits -= uint(bitWidth)
    }
    return n
}

这个版本benchmark下来,速度是标量版本的约3倍。再叠加前面提到的泛型位宽特化,让bitWidth也变成编译期常量,速度还能进一步提升。

3. 位置汇编计数:AI发现的“隐藏2倍加速”

编码器套上同样的SIMD打包和AVX512异常收集手法之后,性能已经大致追平了cgo版本。但作者说,接下来发生的事让他很惊讶——Claude Fable 5指出,编码器的“扫描”阶段还可以用一种叫“位置汇编计数”(Positional Popcount)的技巧再提速一倍

回忆一下前文提到的发现:编码器真正的瓶颈不是编码本身,而是扫描全部输入值、统计出一份“在每个位宽下需要多少个异常值”的直方图

type stats struct {
    // cnt[n]:有多少个值的bits.Len32(val) > n
    // 即位宽为n时,需要多少个异常值
    cnt [32 + 24]uint32
}

func scan(output *stats, vals []uint32) {
    for _, val := range vals {
        for b := range bits.Len32(val) {
            output.cnt[b]++
        }
    }
}

用3个示例值直观理解:23(二进制0000010111,需要5位)、5(二进制0000000101,需要3位)、666(二进制1010011010,需要10位)。它们各自的“smear mask”(把最高位的1“抹开”到所有低位)分别是000001111100000001111111111111。用POPCNT可以高效地数出“一行”里有多少个1,但这里需要的是数“一列”——即所有输入值在第b位上一共有多少个1,这就是Positional Popcount,标准POPCNT指令帮不上忙。

作者参考了三篇论文/文章(2019年Klarqvist等人的AVX512进位保留加法器方案、2024年Harold Aptroot基于GF2P8AFFINEQB指令的实现、2025年Clausecker等人的改进版),最终选用了GF2P8AFFINEQB路线。

下面这张图梳理了位置汇编计数的核心流程:

值得一提的是,GF2P8AFFINEQB这条指令并不冷门——它也是Go 2025年推出的“Green Tea垃圾回收器”里的“压轴主角”。用Go SIMD实现出来是这样的:

func scanSIMD(output *stats, vals []uint32) {
    ones16 := archsimd.BroadcastUint32x16(^uint32(0))
    shuffle := archsimd.LoadUint8x64Array(&scanShuffle)
    units := archsimd.LoadUint8x64Array(&scanUnits)
    var acc archsimd.Uint8x64
    idx := 0
    for ; idx+16 <= len(vals); idx += 16 {
        v := archsimd.LoadUint32x16(vals[idx : idx+16])
        // 把每个值替换成它的smear mask
        smear := ones16.ShiftRight(v.LeadingZeros()).ReshapeToUint8s()
        // 先转置字节,再转置比特
        matrices := smear.Permute(shuffle).ReshapeToUint64s()
        transposed := units.GaloisFieldAffineTransform(matrices, 0)
        // 一次性对64字节做popcount并累加
        acc = acc.Add(transposed.OnesCount())
    }
    sum := acc.GetLo().ExtendToUint16().Add(acc.GetHi().ExtendToUint16())
    sum.GetLo().ExtendToUint32().Store(output.cnt[0:16])
    sum.GetHi().ExtendToUint32().Store(output.cnt[16:32])
    // 剩下不足16个值的标量兜底路径
    for _, val := range vals[idx:] {
        for b := range bits.Len32(val) {
            output.cnt[b]++
        }
    }
}

原本一个快速版的标量扫描函数,处理每个值大约需要12条指令;用上这套SIMD正交变换后,降到了每个值约1.5条指令——提速约8倍,直接反映在整体编码性能上,就是那个额外的2倍加速

下面这张图汇总了整个优化过程的性能演进路径:

反超之后:Go离C到底还差多远?

作者很诚实地指出,虽然新的Go实现已经超过了DCS过去用的cgo版本,但如果把同样的AVX512核函数和位置汇编计数技巧“对等移植”回C版TurboPFor,Go目前的benchmark结果仍然慢了约1.4倍。差距主要来自五个方面:

  1. 仍有部分标量路径:比如编码器里处理VB异常的函数、给所有位宽同时定价的逻辑,还可以进一步SIMD化,但这会让代码更难懂,作者对此保持谨慎;
  2. 边界检查(bounds checking):这是Go为了内存安全而付出的代价,作者明确表示不会为了性能关掉它,未来的优化空间在于让编译器的“证明”(prove)阶段更聪明地消除不必要的检查;
  3. 中栈内联(mid-stack inlining)产生的NOP填充:为了在二进制里放置内联标记,Go有时会插入额外的NOP指令,这对指令派发瓶颈型的函数是实打实的开销;
  4. 无法针对具体CPU型号定制:Go目前只能指定到GOAMD64=v3/v4这一级的微架构,而不能像clang那样精确到“AMD Zen 4”。例如Go编译器会在每条POPCNT前插入XORL CX,CX,这是为了规避Intel Sandy Bridge到Skylake时代的“伪输出依赖”问题,但在AMD Zen芯片上其实并不需要;
  5. 局部代码生成细节的差距:比如一个简单的循环变量自增操作,Go需要3条指令(POPCNTL; ADDQ; LEAQ),clang只需要2条(popcnt; lea)。这类差距是否值得修复,往往因场景而异。

小结:Go SIMD的意义,以及AI在其中的角色

Go的SIMD支持,第一次让开发者能够不依赖cgo、不依赖手写汇编,就用上现代CPU里那部分强大的向量计算能力——对TurboPFor这类计算而言,这是数量级的提速。

作者也坦率地分享了AI编程助手在这个过程中的价值:他用Claude Code(搭配Opus 5和Fable 5)处理了大量繁琐的性能调优工作——阅读objdump反汇编输出的速度远超人类,能发现人很难注意到的模式和关联,遇到编译错误或运行时panic也不会烦躁,只要给出可衡量、可达成的目标,它可以不知疲倦地反复试验。

作者特别强调,他没有“vibe coding”整个项目,而是在AI给出优化建议后亲自审阅、理解、确认。

从CPU性能计数器看,最终版本的数值解码速度达到了每周期7条指令(IPC),而这颗CPU的理论上限是8 IPC——已经相当接近极限。

对Go生态而言,SIMD支持的到来意味着更多原本必须依赖C语言或汇编才能达成的高性能场景,现在可以用纯Go实现,同时保留Go在内存安全、可维护性和跨平台交叉编译上的一贯优势。正如作者在文末所说:这是Go一个非常受欢迎的补充能力


参考资料

  • 原文:Michael Stapelberg,《Debian Code Search: Fast TurboPFor with Go SIMD》 https://michael.stapelberg.ch/posts/2026-09-06-dcs-fast-turbopfor-go-simd/
  • Go 1.26发布说明中关于simd/archsimd的介绍:https://go.dev/doc/go1.26#simd
  • 作者2019年的TurboPFor原理分析:https://michael.stapelberg.ch/posts/2019-02-05-turbopfor-analysis/
  • 作者2019年的DCS倒排索引/TurboPFor压缩实现:https://michael.stapelberg.ch/posts/2019-09-29-dcs-positional-turbopfor-index/
  • Debian/dcs项目相关commit:https://github.com/Debian/dcs
  • 位置汇编计数相关论文:
    • Klarqvist, Muła, Lemire (2019),《Efficient Computation of Positional Population Counts Using SIMD Instructions》:https://arxiv.org/abs/1911.02696
    • Harold Aptroot (2024),《Histogramming bytes with positional popcount (GF2P8AFFINEQB edition)》:https://bitmath.blogspot.com/2024/11/histogramming-bytes-with-positional.html
    • Clausecker, Lemire, Schintke (2025),《Faster Positional-Population Counts for AVX2, AVX-512, and ASIMD》:https://arxiv.org/abs/2412.16370

还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。