You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
-
很多人提到 C#,脑海里先出现的是 Web API、业务系统,或者一层层对象。把它放到 DVB-CSA 这样的老牌位切片密码算法里,直觉上多少有些别扭:FFdecsa 是为 C、宏和特定 CPU 后端写的,热循环里每一条指令都很敏感;C# 则有 GC、JIT 和抽象层。
FFDecsaSharp 的目标不是“用 C# 抄一遍 C”,而是把 FFdecsa 对 188 字节 MPEG-TS 包的解密能力,重建成一个现代 .NET 库:API 可以直接嵌进应用,热路径没有托管分配,能 AOT 发布,也保留接近原实现的批处理性能。
最终做到的事情比一开始预想得更有意思:在 Apple M4 上,C# 实现达到所选 FFdecsa C 后端约 97% 的吞吐;在一台 Core i5-10400F 上,同一套规范下的 C# 结果还快了约 8%。这并不意味着“C# 打败了 C”,但足以说明现代 .NET 已经可以认真处理这类过去默认交给 C 的工作。
先别谈性能,先把问题拆开
FFdecsa 的代码并不适合直接翻译。它把协议处理、控制字调度、流密码、分组密码、位切片布局和不同平台的并行宏交织在一起。直接照着
#define改成 C#,短期可能跑起来,之后很难验证,也很难继续优化。迁移从两个很“慢”的基础工作开始。
第一步是先把 MPEG-TS 的边界讲清楚:同步字、188 字节包长、扰码控制位、adaptation field 和 payload offset。不是每个包都该解密,也不是每个 payload 都是 184 字节整齐的完整块。FFDecsaSharp 先实现
TransportPacket、ControlWord、包规划器,再给这些规则写单元测试。第二步才是密码核心。先移植控制字的 stream/block key schedule,再写一个单包的标量版本:流密码产生字节流,分组密码处理 8 字节块,最后按 DVB-CSA 的链式规则拼起来。这个阶段的标准不是“看上去像”,而是 FFdecsa 自带测试向量、完整包、adaptation field 和残余字节全部对上。
这条路线有点笨,却让后面的优化有了可靠的锚点。批处理实现、SIMD 实现都可以随时和标量路径逐包比对,而不是只相信某次 benchmark 的漂亮数字。
从单包到 128 包:关键不是“写 SIMD”
DVB-CSA 的流密码天然适合 bitslice:把 128 个包的同一位放进一个位平面,一次布尔运算就是 128 路并行。FFdecsa 正是靠这个思路取得高性能。
C# 里同样可以表达这一点,但写法不必退回到 C 宏。FFDecsaSharp 用
Vector128<ulong>表示一个位平面,用Span<T>和stackalloc管理临时工作区;完整的 128 包组会走位切片路径,只有一个包、带 adaptation field 的包或残余字节则回退到已验证的标量路径。这里有两个容易被忽略的细节。
其一,批处理并不要求输入里同一种控制字的包连续出现。实现会分别收集偶密钥和奇密钥的包索引,解密后仍写回原位置。对真实 TS 流而言,这比“连续同密钥才加速”更实用。
其二,性能瓶颈常常不在布尔网络本身,而在数据布局。早期版本已经能用 128 路向量计算,却要在“按包存放”和“按位平面存放”之间做很多零碎转换。后来按 FFdecsa 的 64×128 位矩阵布局实现完整转置,流密码的布尔网络无需改动,Apple M4 上端到端吞吐却提升了约 25%。这也是这次迁移最有价值的经验:算法相同,数据怎么摆放,往往决定了性能上限。
现代 .NET 在这里具体帮上了什么
“现代 .NET 很快”如果不落到代码上,听起来和广告没什么区别。FFDecsaSharp 中真正有用的是下面几件事。
Span<T>/ReadOnlySpan<T>让库的输入输出直接指向调用方缓冲区。单包和批量 API 都是原地解密,不需要为了接口方便再复制一份byte[]。热路径中的中间状态放在stackalloc的栈缓冲区里,基准测试中托管分配为 0 B。System.Runtime.Intrinsics让位切片的意图能直白地写出来。Vector128<ulong>不是某个厂商专用的汇编包装;在 Arm64 上由 AdvSIMD 承载,在 x64 上由可用的 SIMD 指令承载。FFDecsaSharp 还为不同平台保留了不同的块密码状态更新和查表路径,而不是把一套指令硬塞到所有 CPU 上。RyuJIT 值得被当作优化对象,而不只是运行环境。迁移过程中多次用 JIT 反汇编确认真正生成了什么代码,例如发现 x64 查表时重复的地址扩展,再把索引和输入指针改成更适合 JIT 的形式。一次小改动让 i5-10400F 上的块密码核心下降了约 16%。反过来也有不少“理论上更省指令”的改动被撤回:更多局部变量、AVX2 gather、16 路 shuffle 查表、压缩循环,实际都更慢。托管代码的优化同样需要测量,不存在靠直觉稳赢的魔法。
NativeAOT 解决的是交付,不是让算法突然变快。库本身刻意避免反射、动态生成和依赖运行时类型扫描;CLI 和 GUI 都可发布为 NativeAOT 二进制。这样命令行工具可以作为单文件原生程序部署到 Windows、macOS 和 Linux,Linux 版本还使用 musl 静态链接,减少宿主 libc 差异。对用户来说,这是“下载后能跑”;对代码结构来说,这是一种持续约束。
还有一点容易被忽略:这类代码最终仍由 RyuJIT 或 NativeAOT 后端生成机器码。未来 .NET 改进寄存器分配、向量指令选择或边界检查消除时,FFDecsaSharp 通常只需使用新版运行时重新构建或运行,就可能直接受益。当然,每次升级仍应重新跑测试和基准,而不是假定性能必然提升。
最后是工程层面的组合能力。核心库不依赖 GUI;上面可以叠一个 Avalonia 桌面端,也可以叠一个支持 JSON 输出、并行度和多语言的 CLI。文件解密把异步 I/O、进度报告、取消和独占工作线程放在库的外层,密码热循环仍保持同步、可预测。这种分层没有多炫,但比把 UI、文件和算法塞进一个入口函数更容易维护。
和 C 版到底怎么比
性能比较最怕“各跑一次,挑好看的数字”。这里专门做了一个共享协议:C# 和 C 都解密同一组确定性的 128 个完整 TS 包;先 warmup 5,000 批,再测量 30,000 批;源包复位和校验放在计时窗口外;最后用同一个 FNV-1a 校验和确认输出一致。比较对象是 FFdecsa 的
PARALLEL_128_2LONG配置,不代表所有 FFdecsa 后端。单线程结果只描述解密核心。实际处理大文件时,FFDecsaSharp 可让多个 worker 解密互不重叠的包区间。在 Apple M4 上,单 worker 实测约 346.8 MB/s,10 workers 约 1729.8 MB/s,约为 5 倍扩展。最终文件速度仍会受到存储、缓存、CPU 温度和 worker 数的影响。
这张表的正确读法是:在这两台机器、这个批次大小、这个 C 后端和这条计时边界下,结果如此。它不是语言排行榜,更不是说任意 C# 写法都能超过任意 C 写法。
尤其在 x64 上,差异主要集中于块密码的查表与状态更新方式;在 Arm64 上,完整 64×128 转置布局是决定性的一步。这样的结果反而比“全面领先”的口号更可信:每个架构都有不同的短板,优化要对应实际机器和实际代码生成。
如果换成 Java 或 Go,会怎样?
从“能不能实现”看,Java 和 Go 都没有问题:DVB-CSA 的输入、输出和位运算规则与语言无关。真正拉开差距的,是把它做成接近 FFdecsa 吞吐的 128 路位切片实现有多难。
Java 的能力最接近 .NET:有成熟 JIT,也有 Vector API。正确的标量版不难;要追求 128 路位切片吞吐,则仍要处理位平面布局、临时缓冲区、逃逸分析和 JIT 代码生成。Java 的热路径在预热后也可能做到 0 B 分配,但通常依赖预分配堆数组、线程本地存储或 HotSpot 成功消除分配;它不像 C# 的
Span<T>与stackalloc那样能直接表达。JDK、内联结果和 Vector API 写法变化,都可能让这一性质失效。因此,即使固定目标 JDK 与 CPU、采用相同布局和严格基准,Java 有机会做出有竞争力的吞吐;但若还要求稳定 0 B、跨 JDK 可重复和可维护,工程难度会明显更高。发布时,Java 的默认选择仍是应用加 JRE,或通过
jlink缩小运行时;也可用 GraalVM Native Image 做 AOT,但需要处理反射、资源和动态加载的 closed-world 配置,并重新验证向量路径的兼容性和性能。AOT 改善的是交付与启动,并不会自动让位切片循环更快。Go 很适合协议解析、文件 I/O 和并发调度,也有很好的发布体验:
go build默认产生原生可执行文件,交叉编译和静态部署都很直接。但它的纯语言层缺少与 .NET Intrinsics 或 Java Vector API 对等、稳定且可移植的显式 SIMD 接口。纯 Go 当然可以实现正确、实用的解密器;若不写架构相关汇编或调用原生后端,单核批量性能预计会与 FFdecsa / 当前 C# 实现拉开明显差距。反过来,汇编或cgo能缩小差距,却会重新带来架构、交叉编译和外部依赖的维护成本。因此这里不为 Java 或 Go 写武断的性能百分比。对这类算法来说,语言标签不如三件事重要:是否采用相同的 64×128 位布局、是否真正进入 SIMD 路径,以及基准是否只测解密核心。
比最终数字更重要的部分
迁移过程中有一条一直坚持的规则:没有可复现收益的优化,不留在主分支。
迁移记录保留了许多被验证后放弃的尝试:NEON
TBL的早期原型、AVX2 gather、16 表 shuffle、把寄存器历史拉长以减少复制、按理论门数重写 S-box,甚至一些看似能消除 bounds check 的代码。这些尝试并非白做,它们至少把“为什么没用”从猜测变成了数据,也避免仓库最后留下几条谁都不敢碰的神秘快路径。因此,FFDecsaSharp 对 FFdecsa 的迁移不只是语言转换:它保留了 C 版对位布局和批处理的尊重,同时换来了 .NET 的类型边界、可测试 API、跨平台交付和更顺手的应用层能力。现代 .NET 的强大之处不在于把底层细节藏起来,而在于需要下沉时,仍能下沉到
Span、栈内存、向量寄存器、JIT 汇编和 AOT;需要向上构建产品时,又不必离开同一套生态。这大概才是这次迁移最值得分享的结论:C# 不是 C 的替身,C 也不是该被淘汰的旧工具。把算法、数据布局、验证和发布方式都选对之后,二者能到达的边界,已经比我们印象里近得多。
FFDecsaSharp 的源码、迁移记录和基准协议都在仓库中:https://github.com/nilaoda/FFDecsaSharp。FFdecsa 参考源码来自 gfto/tsdecrypt,FFDecsaSharp 按 GPLv3 发布。运行基准时,请以仓库中的协议与机器信息为准。
Beta Was this translation helpful? Give feedback.
All reactions