diff --git a/_config.yml b/_config.yml index e9f6b67..97cf2e0 100644 --- a/_config.yml +++ b/_config.yml @@ -84,7 +84,7 @@ pageviews: # light — Use the light color scheme # dark — Use the dark color scheme # -theme_mode: dark +theme_mode: # The CDN endpoint for media resources. # Notice that once it is assigned, the CDN url diff --git a/_includes/metadata-hook.html b/_includes/metadata-hook.html new file mode 100644 index 0000000..c49e35b --- /dev/null +++ b/_includes/metadata-hook.html @@ -0,0 +1,13 @@ + + + diff --git a/_plugins/reliable-mathjax-loading.rb b/_plugins/reliable-mathjax-loading.rb new file mode 100644 index 0000000..cc1de10 --- /dev/null +++ b/_plugins/reliable-mathjax-loading.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Chirpy loads both the MathJax configuration and runtime with `async`. +# That makes their execution order nondeterministic: the runtime can start +# before the configuration that enables `$...$` inline delimiters. +module ReliableMathJaxLoading + CONFIG_PATTERN = %r{} + POLYFILL_PATTERN = %r{} + RUNTIME_PATTERN = %r{} + + def self.rewrite(html) + html + .sub(CONFIG_PATTERN, '') + .sub(POLYFILL_PATTERN, '') + .sub(RUNTIME_PATTERN, '') + end +end + +Jekyll::Hooks.register %i[pages documents], :post_render do |document| + next unless document.output_ext == '.html' && document.data['math'] + + document.output = ReliableMathJaxLoading.rewrite(document.output) +end diff --git a/_posts/ai-auto-note/paper-reading/2026-07-30-int-vs-fp-fine-grained-low-bit-quantization-formats.md b/_posts/ai-auto-note/paper-reading/2026-07-30-int-vs-fp-fine-grained-low-bit-quantization-formats.md new file mode 100644 index 0000000..8568c2b --- /dev/null +++ b/_posts/ai-auto-note/paper-reading/2026-07-30-int-vs-fp-fine-grained-low-bit-quantization-formats.md @@ -0,0 +1,700 @@ +--- +title: "INT v.s. FP" +date: 2026-07-30 16:02:41 +0800 +categories: ["ai-auto-note", "paper-reading"] +tags: ["ai-auto-note", "paper-reading", "quantization", "low-bit", "int", "fp"] +math: true +--- + +## 背景 + +我在为量化库加入 rotation 接口时,使用了 rotation + NVFP4 作为例子。做消融实验时发现,rotation 几乎没有带来提升,甚至出现了负优化。于是想起了以前别人对我的指点: + +> 低精度量化里 int 比 fp 好是好在和旋转算法更亲和。实际上到了低精度阶段例如 4bit,fp 和 int 相差不大,而且低精度都是 per-group 量化,粒度很细,fp 的稠密区已经很难和小块内的分布匹配上了,int 和 fp 都差不多,更多的是每个芯片上会有自己的商业决策,有什么用什么。int 和 fp 的区别在 8bit 才比较明显,动态范围和精度甜区差得多。 + +因此,我计划通过这篇论文系统学习 fine-grained 量化下 INT 和 FP 的误差差异,并解释自己的实验现象。 + +## 论文信息 + +- 标题:[INT v.s. FP: A Comprehensive Study of Fine-Grained Low-bit Quantization Formats](https://arxiv.org/pdf/2510.25602) +- 代码:[ChenMnZ/INT_vs_FP](https://github.com/ChenMnZ/INT_vs_FP) + +### TL;DR + +在论文采用的 block size、AbsMax scaling、模型和量化流程范围内,主要结论是: + +- **8-bit:MXINT8 > MXFP8。** MXINT8 在 10,752 个真实 tensor 实例上的平均 QSNR 更高,并且在论文测试的 12 个模型上都取得了更小的 direct-cast 推理偏差。 +- **4-bit MX:MXFP4 > MXINT4。** 即使使用 random Hadamard rotation,MXINT4 在论文的张量和模型实验中仍未超过 MXFP4。 +- **4-bit NV:NVINT4 + Hadamard rotation > NVFP4。** 未旋转时二者平均 QSNR 接近,但 NVINT4 略低;rotation 降低 crest factor 后,NVINT4 才明确反超。 +- **INT对 Rotate 亲和性更好。** INT 的均匀量化步长直接受 block 最大值影响;FP 的非均匀码点本身已经能够缓解一部分动态范围问题,因此 rotation 对 FP 的收益通常较小,甚至可能略微降低 QSNR。 +- **在论文的硬件成本模型中,INT 更便宜。** 在 matched-throughput 的 gate-level 模型下,MXINT8 和 NVINT4 的面积、能耗均低于对应 FP 格式;但这不等同于真实 GPU 的端到端吞吐或功耗测试。 + +论文使用 crest factor $ \kappa $ 表示 block 内 outlier 相对 RMS 的突出程度,并求出 INT 和 FP 理论 QSNR 的交点: + +| 格式比较 | INT 理论胜出的条件 | +| --- | --- | +| MXINT8 vs MXFP8 | κ < 7.55 | +| MXINT6 vs MXFP6 | κ < 1.96 | +| MXINT4 vs MXFP4 | κ < 2.04 | +| NVINT4 vs NVFP4 | κ < 2.39 | + +block 越小,或者 Hadamard rotation 越能抑制离群值,$ \kappa $ 通常越低,INT 越有优势。不过,4-bit INT 的均匀码点较少,MXINT 还要承受 UE8M0 scale overhead;FP4 则利用 exponent 提供非均匀动态范围,因此 INT4 需要比 INT8 更低的 $ \kappa $ 才能胜出。 + + **rotation + NVFP4 没有提升,甚至略有负优化** 的原因: + +1. group-wise=16 是很细的粒度,并且 nvfp4 的 exponent 已经能够覆盖大部分 block 内的动态范围,因此 rotation 对 NVFP4 的提升有限,甚至没有提升; +2. rotation 本身并不会偏向某一种 dtype,但它会改变 block 内的幅值分布。对 NVINT4 而言,outlier 被平滑后,AbsMax scale 和均匀量化步长会直接缩小;对 NVFP4 而言,E2M1 的 exponent 动态范围优势随之减弱,而 1-bit mantissa 的局部精度限制开始占主导。旋转后的 crest factor 低于 NVINT4/NVFP4 的 crossover point,因此 NVINT4 能够在相同的旋转后张量上系统性地超过 NVFP4。 + +因此,rotation 并不是与所有低比特格式都能正交叠加的通用增益项。它更像是在降低数据分布对均匀量化器的不友好程度:对于 INT4,rotation 可能决定能否越过 crossover point;对于 NVFP4,则可能没有收益。 + +### 具体实验 + +论文比较的都是较细粒度的 block-wise 格式: + +| Format | Block Size | Max Value | Min Value | Dynamic Range | Scale-1 | Scale-2 | +| :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| MXFP8(E4M3) | 32 | $ \pm448 $ | $ 2^{-9} $ | $ 1.75\times2^{17} $ | UE8M0 | - | +| MXINT8 | 32 | $ \pm127 $ | $ 1 $ | 127 | UE8M0 | - | +| MXFP6(E2M3) | 32 | $ \pm7.5 $ | $ 0.125 $ | 60 | UE8M0 | - | +| MXINT6 | 32 | $ \pm31 $ | $ 1 $ | 31 | UE8M0 | - | +| MXFP4(E2M1) | 32 | $ \pm6 $ | $ 0.5 $ | 12 | UE8M0 | - | +| MXINT4 | 32 | $ \pm7 $ | $ 1 $ | 7 | UE8M0 | - | +| NVFP4 | 16 | $ \pm6 $ | $ 0.5 $ | 12 | E4M3 | FP32 | +| NVINT4 | 16 | $ \pm7 $ | $ 1 $ | 7 | E4M3 | FP32 | + +表中的动态范围只描述元素 codebook,不包含 block scale 扩展后的整体动态范围。 + +| 理论 QSNR | 实测 QSNR | +| --- | --- | +| 不同量化格式的理论 QSNR | 不同量化格式的实测 QSNR | + +- **QSNR:** 越高表示相对量化误差越小。 +- **crest factor:** 峰值因子,用于衡量一组数中的最大绝对值相对整体典型大小有多突出;在这里用于表征离群值严重程度。 +- **Figure 4 的数据来源:** Figure 4 不是先用低精度模型完成推理再采集数据,而是先运行 BF16 模型采集真实张量,再对这些张量做离线 fake quantization。 +- Figure 4(a) 是原始张量结果;Figure 4(b) 在量化前对 INT 和 FP 张量都应用了 random Hadamard rotation。**NVINT4 相对 NVFP4 的 tensor-wise win rate 从 64.3% 提升到 99.3%**;平均 QSNR 从 20.55 dB 提升到 21.65 dB。NVFP4 的平均 QSNR 则从 20.60 dB 小幅降至 20.35 dB,这与我的实验现象一致。 +- Figure 4 中的每个点对应一个完整 tensor 实例:横坐标是该 tensor 内所有 block 的平均 crest factor,纵坐标是整个 tensor 的 QSNR。因此它不是逐 block 的散点图,平均 crest factor 相同的 tensor 仍可能具有不同 QSNR。 +- direct-cast inference 使用 Qwen3 和 Llama 3 系列共 12 个模型,覆盖 dense 和 MoE 架构,并以量化模型相对 BF16 模型 top-25 logits 的 KL divergence 衡量偏差。加入 rotation 后,MXINT8 和 NVINT4 均在 12/12 个模型上胜出,MXINT4 仍为 0/12。 +- 训练实验只比较 8-bit:论文在 1B/100B tokens 和 3B/200B tokens 的训练中发现,MXINT8、MXFP8 与 BF16 的 loss 曲线接近;对 INT 使用对称范围 $ [-127,127] $,可以避免 two's-complement 非对称范围引入的梯度负偏。 + +实测的 QSNR–crest factor 关系与理论曲线的整体趋势较为一致。 + +### 理论误差是如何计算的 + +#### TL;DR + +Figure 3 不是通过真实模型实验得到的,而是由理论量化误差公式计算出来的。整体流程是: + +> 假设 block 内数据近似服从高斯分布 +> → 使用 crest factor κ 描述 block 内的离群程度 +> → 推导 INT 和 FP 的相对量化误差 +> → 转换为 QSNR +> → 扫描不同的 κ +> → 找到 INT 和 FP 曲线的交点 + +对于 INT,实际 block scale 同时也是均匀量化步长。使用 round-to-nearest 且不发生 clipping 时: + +$$ +\mathbb{E}[e^2] +\approx +\frac{\Delta^2}{12} +$$ + +最终得到: + +$$ +\mathrm{QSNR}_{\mathrm{INT}} +\approx +4.78 ++ +6.02b +- +20\log_{10}\rho +- +20\log_{10}\kappa +$$ + +因此,bit 数 $ b $ 越高,INT 的 QSNR 越高;crest factor $ \kappa $ 或 UE8M0 scale overhead $ \rho $ 越大,INT 的 QSNR 越低。 + +对于 FP,误差主要分为 normal 和 subnormal 两部分。数据主要位于 normal 区域时,FP 的精度上限主要由 mantissa bit 数 $ M $ 决定: + +$$ +\mathrm{QSNR}_{\mathrm{FP}} +\approx +13.80+6.02M +$$ + +因此,FP 的 QSNR 更接近一条由 mantissa 精度决定的水平线,而 INT 的 QSNR 会随着 $ \kappa $ 增大而下降。令两种格式的理论 QSNR 相等,就能得到 crossover point。 + +#### 1. 数据分布假设 + +论文假设一个包含 $ g $ 个元素的量化 block: + +$$ +X=(X_1,\ldots,X_g) +$$ + +block 中的元素独立同分布: + +$$ +X_i\sim\mathcal{N}(0,\sigma^2) +$$ + +在这个假设下,block 的 RMS 可以近似为 $ \sigma $。 + +crest factor 定义为: + +$$ +\kappa += +\frac{ +\max_i |X_i| +}{ +\sqrt{ +\frac{1}{g} +\sum_{i=1}^{g}X_i^2 +} +} +\approx +\frac{\max_i |X_i|}{\sigma} +$$ + +因此: + +$$ +\max_i |X_i| +\approx +\kappa\sigma +$$ + +$ \kappa $ 越大,表示 block 中的峰值相对普通数值越突出,也就是离群值越严重。 + +#### 2. Scale 的计算 + +对于 AbsMax 量化,理想 scale 为: + +$$ +s += +\frac{\max_i |X_i|}{Q_{\max}} += +\frac{\kappa\sigma}{Q_{\max}} +$$ + +其中,$ Q_{\max} $ 是目标数据格式的最大正数值。例如,对称 INT4 的表示范围是 $ [-7,7] $,所以: + +$$ +Q_{\max}=7 +$$ + +通过 AbsMax 计算得到的理想 scale 是高精度实数,但实际格式需要使用指定的低精度 scale dtype 表示。论文使用 $ \rho $ 建模 low-precision scale overhead: + +$$ +s'=\rho s +$$ + +其中: + +- $ s $ 是通过 AbsMax 计算得到的高精度理想 scale; +- $ s' $ 是按照目标 scale dtype 表示后实际使用的 scale; +- $ \rho $ 是低精度 scale 表示相对理想 scale 引入的放大比例。 + +对于 MX 格式,scale 使用 UE8M0,只能表示 2 的整数次幂。为了避免 clipping,论文将理想 scale 向上舍入: + +$$ +s' += +2^{\left\lceil\log_2s\right\rceil} += +\rho s +$$ + +因此: + +$$ +1\leq\rho<2 +$$ + +Figure 3 为了画出一条代表性的理论曲线,对 MX 格式固定采用: + +$$ +\rho=1.5 +$$ + +论文没有给出 $ 1.5 $ 的真实数据拟合或统计推导;它是用于模拟 UE8M0 scale overhead 的启发式代表值。实际量化时,每个 block 的 $ \rho $ 会随理想 scale 改变,因此 crossover point 也会相应移动。 + +对于使用 E4M3 block scale 的 NV 格式,论文在理论分析中近似令 $ \rho=1 $。 + +#### 3. INT 的理论 QSNR + +INT 使用均匀量化,所有相邻量化值之间的间隔相同。实际使用的 block scale 同时也是相邻反量化码点之间的量化步长: + +$$ +\Delta += +s' += +\frac{\rho\kappa\sigma}{Q_{\max}} +$$ + +对于论文采用的对称 $ b $ bit INT: + +$$ +Q_{\max}=2^{b-1}-1 +$$ + +所以精确地写为: + +$$ +\Delta += +\frac{\rho\kappa\sigma}{2^{b-1}-1} +$$ + +理论推导为了得到更简洁的表达式,使用: + +$$ +2^{b-1}-1 +\approx +2^{b-1} +$$ + +从而得到: + +$$ +\Delta +\approx +\frac{\rho\kappa\sigma}{2^{b-1}} +$$ + +在经典的高分辨率均匀量化假设下,如果 quantizer 使用 round-to-nearest 且不发生 clipping,量化误差近似均匀分布在: + +$$ +\left[ +-\frac{\Delta}{2}, +\frac{\Delta}{2} +\right] +$$ + +$ \Delta/2 $ 是最大绝对舍入误差;QSNR 使用的是误差平方的平均值。对该均匀分布求均方值可得: + +$$ +\mathbb{E}[e^2] +\approx +\frac{\Delta^2}{12} +$$ + +代入量化步长: + +$$ +\mathbb{E}[e^2] +\approx +\frac{ +\rho^2\kappa^2\sigma^2 +}{ +12\cdot2^{2(b-1)} +} +$$ + +由于 $ X\sim\mathcal{N}(0,\sigma^2) $ 且均值为 0,原始数据的均方值为: + +$$ +\mathbb{E}[X^2]=\sigma^2 +$$ + +这里的“signal power”不是物理功率,而是原始张量元素的平均平方值,也就是 RMS 的平方。用量化误差的均方值除以原始数据的均方值,得到相对均方误差: + +$$ +\frac{\mathbb{E}[e^2]}{\sigma^2} +\approx +\frac{ +(\rho\kappa)^2 +}{ +12\cdot2^{2(b-1)} +} +$$ + +根据 QSNR 的定义: + +$$ +\mathrm{QSNR}_{\mathrm{INT}} += +-10\log_{10} +\left( +\frac{\mathbb{E}[e^2]}{\sigma^2} +\right) +$$ + +代入展开得到: + +$$ +\boxed{ +\mathrm{QSNR}_{\mathrm{INT}} +\approx +4.78 ++ +6.02b +- +20\log_{10}\rho +- +20\log_{10}\kappa +} +$$ + +这个公式表明: + +- 位宽 $ b $ 每增加 1 bit,QSNR 大约提高 $ 6.02\,\mathrm{dB} $; +- crest factor $ \kappa $ 越大,INT 的 QSNR 越低; +- $ \kappa $ 每翻倍,INT 的 QSNR 大约下降 $ 6.02\,\mathrm{dB} $; +- scale overhead $ \rho $ 越大,INT 的 QSNR 越低; +- block 越小,局部 crest factor 通常越低,因此 INT 越有优势。 + +INT 对 crest factor 敏感的原因是,整个 block 使用固定的均匀量化步长: + +$$ +\ldots,-2\Delta,-\Delta,0,\Delta,2\Delta,\ldots +$$ + +如果某个离群值将 block 的最大绝对值拉大,量化步长 $ \Delta $ 也会随之增大,导致大量普通数值被量化到相同的码点。 + +对于使用高精度 E4M3 block scale 的 NVINT,论文给出的近似公式为: + +$$ +\mathrm{QSNR}_{\mathrm{NVINT}} +\approx +4.78 ++ +6.02b +- +20\log_{10}\kappa ++ +10\log_{10} +\left( +\frac{g}{g-1} +\right) +$$ + +其中,$ g $ 是 block size;对 NVINT4 而言,$ g=16 $。额外的修正项: + +$$ +10\log_{10} +\left( +\frac{g}{g-1} +\right) +$$ + +来自 block 最大值可以被较准确表示这一性质。 + +#### 4. FP 的理论 QSNR + +FP 的量化间隔不是固定的,而是随着 exponent 变化。论文将 FP 的量化误差分成两个区域: + +- **normal 区域:** 有效数字包含隐含的前导 $ 1 $,量化间隔会随着 exponent 增大而扩大,因此具有近似固定的相对精度。 +- **subnormal 区域:** 用于表示靠近 $ 0 $、但小于最小 normal number 的非零数。此时不再使用隐含的前导 $ 1 $,量化间隔固定,因此不再具有固定的相对精度。 + +以 E2M1 为例: + +- zero:$ 0 $; +- subnormal:$ \pm0.5 $; +- normal:$ \pm1 $、$ \pm1.5 $、$ \pm2 $、$ \pm3 $、$ \pm4 $、$ \pm6 $。 + +经过 block scale 缩放后,最小 normal number 在原始数据域中的阈值为: + +$$ +T_N += +s'2^{1-B} +$$ + +论文定义: + +$$ +p_{\mathrm{sub}} += +\mathbb{P}(|X| .container { + width: 100%; + max-width: 1600px; + } + } + + main.col-12, + #tail-wrapper { + width: 100%; + max-width: 100%; + } + + #sidebar-trigger i::before { + content: '\f054'; + } + } +} + +@media (min-width: 1200px) { + html[data-sidebar-collapsed] { + main.col-xl-9, + #tail-wrapper.col-xl-9 { + width: 100%; + max-width: 1280px; + margin-right: auto; + margin-left: auto; + } + + #panel-wrapper.col-xl-3 { + display: none; + } + } +} + +#sidebar .sidebar-bottom #mode-toggle { + min-width: 44px; + min-height: 44px; + cursor: pointer; +} + +#sidebar .sidebar-bottom #mode-toggle:focus-visible { + outline: 2px solid var(--link-color); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + #sidebar, + #main-wrapper { + transition: none; + } +} diff --git a/_sass/warm-light-theme.scss b/_sass/warm-light-theme.scss new file mode 100644 index 0000000..c87c72d --- /dev/null +++ b/_sass/warm-light-theme.scss @@ -0,0 +1,101 @@ +@mixin warm-light-colors { + color-scheme: light; + + /* Warm, paper-like surfaces */ + --main-bg: #f5f0e3; + --main-border-color: #ded5c2; + --mask-bg: #a79f91; + --card-bg: #faf7ef; + --card-hovor-bg: #ebe2cf; + --button-bg: #faf7ef; + + /* Warm neutral typography */ + --text-color: #3d3931; + --text-muted-color: #6d665a; + --text-muted-highlight-color: #514b41; + --heading-color: #29261f; + --label-color: #514b41; + --post-list-text-color: #625c51; + + /* Muted terracotta accents */ + --link-color: #8a492c; + --link-underline-color: #cdbca7; + --toc-highlight: #8a492c; + --checkbox-checked-color: #9b5635; + --btn-share-hover-color: #8a492c; + + /* Sidebar and top bar */ + --site-title-color: #4c463c; + --site-subtitle-color: #746c5e; + --sidebar-bg: #ece5d5; + --sidebar-border-color: #d8ceba; + --sidebar-muted-color: #625b4f; + --sidebar-active-color: #2f2b24; + --sidebar-hover-bg: #ded4c1; + --sidebar-btn-bg: #f7f2e7; + --sidebar-btn-color: #6d6558; + --avatar-border-color: #faf7ef; + --topbar-bg: rgb(245, 240, 227, 0.88); + --topbar-text-color: #514b41; + --search-border-color: #d8ceba; + --search-icon-color: #8b8274; + --input-focus-border-color: #a99d8b; + + /* Dividers, tables, tags, and controls */ + --btn-border-color: #d8ceba; + --btn-backtotop-color: #625b4f; + --btn-backtotop-border-color: #d8ceba; + --btn-patinator-text-color: #514b41; + --btn-paginator-hover-color: #e4dac7; + --blockquote-border-color: #cfc2aa; + --blockquote-text-color: #665f53; + --toc-popup-border-color: #d8ceba; + --tag-border: #d4c8b3; + --tag-shadow: #ded5c2; + --tag-hover: #e4dac7; + --search-tag-bg: #ece4d5; + --categories-border: #d8ceba; + --categories-hover-bg: #e4dac7; + --timeline-color: #d8ceba; + --timeline-node-bg: #a99d8b; + --timeline-year-dot-color: #f5f0e3; + --tb-odd-bg: #f0eadc; + --tb-border-color: #ded5c2; + --dash-color: #b7ab98; + --kbd-wrap-color: #b7ab98; + --kbd-bg-color: #ebe3d3; + + /* Code and image placeholders */ + --language-border-color: #d8ceba; + --highlight-bg-color: #ebe5d8; + --inline-code-bg: rgb(92, 76, 53, 0.09); + --code-color: #493f34; + --code-header-text-color: #746b5e; + --code-header-muted-color: #d8ceba; + --code-header-icon-color: #948979; + --img-bg: radial-gradient(circle, #faf7ef 0%, #e8e0d1 100%); + --shimmer-bg: linear-gradient( + 90deg, + rgb(245, 240, 227, 0) 0%, + #ded5c2 50%, + rgb(245, 240, 227, 0) 100% + ); + + .highlight, + .highlight .w { + background-color: var(--highlight-bg-color); + } +} + +@media (prefers-color-scheme: light) { + html:not([data-mode]), + html[data-mode='light'] { + @include warm-light-colors; + } +} + +@media (prefers-color-scheme: dark) { + html[data-mode='light'] { + @include warm-light-colors; + } +} diff --git a/assets/css/jekyll-theme-chirpy.scss b/assets/css/jekyll-theme-chirpy.scss new file mode 100644 index 0000000..c08e0c2 --- /dev/null +++ b/assets/css/jekyll-theme-chirpy.scss @@ -0,0 +1,10 @@ +--- +--- + +@use 'main +{%- if jekyll.environment == 'production' -%} + .bundle +{%- endif -%} +'; +@use 'sidebar-controls'; +@use 'warm-light-theme'; diff --git a/assets/img/int-vs-fp-empirical-qsnr.png b/assets/img/int-vs-fp-empirical-qsnr.png new file mode 100644 index 0000000..e9451c9 Binary files /dev/null and b/assets/img/int-vs-fp-empirical-qsnr.png differ diff --git a/assets/img/int-vs-fp-theoretical-qsnr.png b/assets/img/int-vs-fp-theoretical-qsnr.png new file mode 100644 index 0000000..0dbab53 Binary files /dev/null and b/assets/img/int-vs-fp-theoretical-qsnr.png differ diff --git a/assets/js/sidebar-controls.js b/assets/js/sidebar-controls.js new file mode 100644 index 0000000..3e76960 --- /dev/null +++ b/assets/js/sidebar-controls.js @@ -0,0 +1,77 @@ +(() => { + const desktop = window.matchMedia('(min-width: 850px)'); + const storageKey = 'sidebar-collapsed'; + + function readPreference() { + try { + return localStorage.getItem(storageKey) === 'true'; + } catch (_) { + return false; + } + } + + function writePreference(collapsed) { + try { + localStorage.setItem(storageKey, String(collapsed)); + } catch (_) { + // Keep the interaction working even when storage is unavailable. + } + } + + function applyState(collapsed) { + const active = desktop.matches && collapsed; + const trigger = document.getElementById('sidebar-trigger'); + + document.documentElement.toggleAttribute('data-sidebar-collapsed', active); + + if (!trigger) { + return; + } + + trigger.setAttribute('aria-controls', 'sidebar'); + trigger.setAttribute('aria-expanded', String(!active)); + + if (desktop.matches) { + const label = active ? '展开左侧个人信息' : '收起左侧个人信息'; + trigger.setAttribute('aria-label', label); + trigger.setAttribute('title', label); + } else { + trigger.setAttribute('aria-label', '打开导航菜单'); + trigger.removeAttribute('title'); + } + } + + function handleTrigger(event) { + if (!desktop.matches) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + + const collapsed = !document.documentElement.hasAttribute('data-sidebar-collapsed'); + writePreference(collapsed); + applyState(collapsed); + } + + function init() { + const trigger = document.getElementById('sidebar-trigger'); + + if (!trigger) { + return; + } + + trigger.addEventListener('click', handleTrigger, { capture: true }); + applyState(readPreference()); + + desktop.addEventListener('change', () => { + applyState(readPreference()); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})();