Skip to content

Commit 0bfca1e

Browse files
committed
feat(mlx): Qwen3.6 dense auto-MTP serving + MTP/kernel research artifacts
Batch checkpoint of the in-flight MTP / native-kernel work. Builds clean; `cargo test -p lumen-mlx --lib --features mlx-native -- --test-threads=1` => 187 passed / 0 failed. Shipping feature — Qwen3.6-27B dense self-speculative MTP serving: - Config-driven MTP head auto-enable: LUMEN_QWEN35_MTP tri-state + model_has_mtp_head() sniff; decode auto-route via effective_qwen35_mtp_k() (K=1 dense sweet spot); LUMEN_QWEN35_HF_ORIGINAL wiring. - resolve_model_dir promoted to pub(crate); 27B-MTPLX catalog entry. Research artifacts (ALL default-OFF, kept in-tree for reuse): - 3-bit requant (requant_linear_affine), gate/up fusion (fuse_gate_up). - Fused linear-attn input path (native_ssm::inproj_tail_fused) + skip/fuse probes + LUMEN_SSM_TG_Y tile sweep. - MTP head-internal LoRA training harness (qwen3_5_mtp: forward_train_lora, HiTrainCfg; runner/lib train_mtp_headinternal) + benches/POCs in examples/. - metal_kernel MetalKernel/Config re-exported pub for the kernel POCs. Not atomic by design: the shipping feature and the default-OFF experiments are interleaved across qwen3_5_moe.rs and cannot be split without interactive hunk staging. Context: memory notes qwen36_27b_auto_mtp_serving_landed, fused_linear_attn_megakernel_project, mtp_c4_trained_adapter_pursuit. Known pre-existing (NOT in this batch): examples/gemma4_backend_mtp_chat_smoke is a stale 8-arg caller of the now-10-arg chat_streaming; multi-threaded `cargo test --lib` SIGSEGVs in MLX FFI tests (harness race) — run single-threaded.
1 parent 4c1cbf9 commit 0bfca1e

15 files changed

Lines changed: 2702 additions & 151 deletions
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//! Isolation BW benchmark for mlx's affine4 `quantized_matmul` at 27B-dense
2+
//! DECODE shapes (matvec, seq=1). Decides whether the baseline-decode
3+
//! BW-efficiency lever has headroom: if mlx's QMV already hits ~85-90% of the
4+
//! M3 Max 300 GB/s peak on these shapes, a custom kernel can't help (lever dead);
5+
//! if it's ~60%, there's room.
6+
//!
7+
//! Measures GB/s = (packed + scales + biases bytes) * iters / elapsed, evaling
8+
//! each iter (mimics per-layer decode dispatch). Run:
9+
//! cargo run --release -p lumen-mlx --features mlx-native --example bench_affine4_qmv_decode
10+
11+
use anyhow::Result;
12+
use mlx_rs::{Array, Dtype};
13+
use std::time::Instant;
14+
15+
fn dtype_bytes(d: Dtype) -> usize {
16+
match d {
17+
Dtype::Uint32 | Dtype::Int32 | Dtype::Float32 => 4,
18+
Dtype::Bfloat16 | Dtype::Float16 | Dtype::Uint16 | Dtype::Int16 => 2,
19+
_ => 4,
20+
}
21+
}
22+
fn arr_bytes(a: &Array) -> usize {
23+
a.shape().iter().product::<i32>() as usize * dtype_bytes(a.dtype())
24+
}
25+
26+
fn bench_shape(name: &str, out: i32, inn: i32, iters: usize, peak_gbs: f64) -> Result<()> {
27+
let gs = 64;
28+
let bits = 4;
29+
// Random weight [out, in] → affine4 quantize (packed u32, scales, biases).
30+
let w =
31+
mlx_rs::random::normal::<f32>(&[out, inn], None, None, None)?.as_dtype(Dtype::Bfloat16)?;
32+
let (wq, scales, biases) = mlx_rs::ops::quantize(&w, gs, bits)?;
33+
wq.eval()?;
34+
scales.eval()?;
35+
biases.eval()?;
36+
// Decode input [1, 1, in] bf16.
37+
let x =
38+
mlx_rs::random::normal::<f32>(&[1, 1, inn], None, None, None)?.as_dtype(Dtype::Bfloat16)?;
39+
x.eval()?;
40+
41+
let wbytes = arr_bytes(&wq) + arr_bytes(&scales) + arr_bytes(&biases);
42+
43+
// Warmup.
44+
for _ in 0..8 {
45+
let y = mlx_rs::ops::quantized_matmul(&x, &wq, &scales, Some(&biases), true, gs, bits)?;
46+
y.eval()?;
47+
}
48+
// Timed: eval each iter (steady per-dispatch decode pattern).
49+
let t0 = Instant::now();
50+
for _ in 0..iters {
51+
let y = mlx_rs::ops::quantized_matmul(&x, &wq, &scales, Some(&biases), true, gs, bits)?;
52+
y.eval()?;
53+
}
54+
let el = t0.elapsed().as_secs_f64();
55+
let per_ms = el / iters as f64 * 1000.0;
56+
let gbs = wbytes as f64 * iters as f64 / el / 1e9;
57+
println!(
58+
"{name:<22} out={out:>6} in={inn:>6} w={:.1}MB {per_ms:>7.3} ms/call {gbs:>6.1} GB/s ({:>4.0}% of {peak_gbs:.0})",
59+
wbytes as f64 / 1e6,
60+
gbs / peak_gbs * 100.0,
61+
);
62+
Ok(())
63+
}
64+
65+
/// Pipelined MLP chain: gate[h→i] then down[i→h], output feeds next input, ALL
66+
/// in one eval (no per-call sync) — mimics the real forward's command buffer
67+
/// where consecutive matmuls pipeline. This is the TRUE in-decode BW of mlx's
68+
/// QMV (vs the eval-per-call number which serializes & shows dispatch latency).
69+
fn bench_mlp_chain(hidden: i32, interm: i32, pairs: usize, peak: f64) -> Result<()> {
70+
let (gs, bits) = (64, 4);
71+
let mk = |out: i32, inn: i32| -> Result<(Array, Array, Array)> {
72+
let w = mlx_rs::random::normal::<f32>(&[out, inn], None, None, None)?
73+
.as_dtype(Dtype::Bfloat16)?;
74+
let q = mlx_rs::ops::quantize(&w, gs, bits)?;
75+
q.0.eval()?;
76+
q.1.eval()?;
77+
q.2.eval()?;
78+
Ok(q)
79+
};
80+
let (gw, gs_, gb) = mk(interm, hidden)?; // gate: hidden -> interm
81+
let (dw, ds_, db) = mk(hidden, interm)?; // down: interm -> hidden
82+
let bytes_pair = arr_bytes(&gw)
83+
+ arr_bytes(&gs_)
84+
+ arr_bytes(&gb)
85+
+ arr_bytes(&dw)
86+
+ arr_bytes(&ds_)
87+
+ arr_bytes(&db);
88+
let mut x = mlx_rs::random::normal::<f32>(&[1, 1, hidden], None, None, None)?
89+
.as_dtype(Dtype::Bfloat16)?;
90+
x.eval()?;
91+
// warmup
92+
for _ in 0..3 {
93+
let g = mlx_rs::ops::quantized_matmul(&x, &gw, &gs_, Some(&gb), true, gs, bits)?;
94+
let d = mlx_rs::ops::quantized_matmul(&g, &dw, &ds_, Some(&db), true, gs, bits)?;
95+
d.eval()?;
96+
}
97+
let t0 = Instant::now();
98+
let mut cur = x.clone();
99+
for _ in 0..pairs {
100+
let g = mlx_rs::ops::quantized_matmul(&cur, &gw, &gs_, Some(&gb), true, gs, bits)?;
101+
cur = mlx_rs::ops::quantized_matmul(&g, &dw, &ds_, Some(&db), true, gs, bits)?;
102+
}
103+
cur.eval()?; // single eval for the whole chain → pipelined
104+
let _ = &mut x;
105+
let el = t0.elapsed().as_secs_f64();
106+
let gbs = bytes_pair as f64 * pairs as f64 / el / 1e9;
107+
println!(
108+
"MLP CHAIN (pipelined) {pairs} pairs {:.0}MB/pair {:.2} ms/pair {:.1} GB/s ({:.0}% of {peak:.0})",
109+
bytes_pair as f64 / 1e6,
110+
el / pairs as f64 * 1000.0,
111+
gbs,
112+
gbs / peak * 100.0,
113+
);
114+
Ok(())
115+
}
116+
117+
fn main() -> Result<()> {
118+
let iters: usize = std::env::var("ITERS")
119+
.ok()
120+
.and_then(|s| s.parse().ok())
121+
.unwrap_or(400);
122+
// M3 Max 36GB = 300 GB/s; override via PEAK_GBS.
123+
let peak: f64 = std::env::var("PEAK_GBS")
124+
.ok()
125+
.and_then(|s| s.parse().ok())
126+
.unwrap_or(300.0);
127+
println!("=== affine4 QMV decode-shape BW (mlx quantized_matmul, seq=1) ===");
128+
println!("iters={iters} peak={peak} GB/s (27B dense: hidden=5120 interm=17408)\n");
129+
130+
// 27B dense MLP decode matmuls.
131+
bench_shape("gate_proj", 17408, 5120, iters, peak)?;
132+
bench_shape("up_proj", 17408, 5120, iters, peak)?;
133+
bench_shape("down_proj", 5120, 17408, iters, peak)?;
134+
// FUSED gate_up (concat along out): 1 dispatch instead of 2 — the lever.
135+
bench_shape("gate_up_FUSED", 34816, 5120, iters, peak)?;
136+
// Attention proj (q/k/v/o) for reference — smaller.
137+
bench_shape("o_proj", 5120, 5120, iters, peak)?;
138+
// lm_head (full vocab) — the draft cost.
139+
bench_shape("lm_head", 248320, 5120, iters, peak)?;
140+
println!();
141+
// PIPELINED chain (true in-forward BW of mlx QMV — the decisive number for
142+
// "is mlx's matmul already BW-optimal or is there custom-kernel headroom").
143+
bench_mlp_chain(5120, 17408, 200, peak)?;
144+
Ok(())
145+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
//! Head-internal MTP C4 PoC — the decisive accept-lever test for dense Qwen.
2+
//!
3+
//! All offline hidden correctors (diagonal, Procrustes) and the frozen-hidden
4+
//! lm_head C4 are DEAD: the head's softmax is insensitive to hidden-L2, and a
5+
//! logit re-aim of a frozen post-block hidden can't generalize. The ONE untested
6+
//! lever is a LoRA trained INSIDE the block (on eh_proj/mtp.fc), so gradients
7+
//! reshape the hidden through the head's own attention + MLP against the trunk's
8+
//! token (`Qwen35MtpBlock::forward_train_lora`). Feasibility (autograd flows
9+
//! through quantized_matmul + fast::sdpa) proven by `mtp_autograd_probe`.
10+
//!
11+
//! This runs the REAL 27B trunk + MTP head: collect k=0 (embed, h_pre, trunk-
12+
//! target) calib over a greedy decode, train the eh_proj LoRA, report in-sample
13+
//! accept before/after. DECISION GATE: if accept doesn't move → the single head
14+
//! is the ceiling (fold). If it moves → next: held-out generalization test.
15+
//!
16+
//! Run (36GB Mac, ~20GB peak):
17+
//! LUMEN_MLX_BACKEND=native \
18+
//! cargo run --release -p lumen-mlx --features mlx-native \
19+
//! --example bench_mtp_headinternal_poc
20+
21+
use anyhow::{Result, anyhow};
22+
use lumen_mlx::{
23+
HiTrainCfg, MlxBackend, MtpLoadQuant, MtpLoraPos, MtpMlpConfig, MtpMoeConfig, Qwen35MtpDims,
24+
load_block_from_hf,
25+
};
26+
27+
fn env_usize(k: &str, d: usize) -> usize {
28+
std::env::var(k)
29+
.ok()
30+
.and_then(|s| s.parse().ok())
31+
.unwrap_or(d)
32+
}
33+
34+
fn env_f32(k: &str, d: f32) -> f32 {
35+
std::env::var(k)
36+
.ok()
37+
.and_then(|s| s.parse().ok())
38+
.unwrap_or(d)
39+
}
40+
41+
fn main() -> Result<()> {
42+
unsafe {
43+
if std::env::var("LUMEN_MLX_BACKEND").is_err() {
44+
std::env::set_var("LUMEN_MLX_BACKEND", "native");
45+
}
46+
}
47+
let model_id = std::env::var("MODEL_ID").unwrap_or_else(|_| {
48+
format!(
49+
"{}/models/Qwen3.6-27B-MTPLX-Speed",
50+
std::env::var("HOME").unwrap_or_default()
51+
)
52+
});
53+
// The 27B MTP head lives in `<model>/mtp/weights.safetensors` → the loader's
54+
// sidecar path. hf_path defaults to the model dir itself.
55+
let hf_dir = std::env::var("LUMEN_QWEN35_HF_ORIGINAL").unwrap_or_else(|_| model_id.clone());
56+
let hf_path = std::path::PathBuf::from(&hf_dir);
57+
58+
let calib_gen = env_usize("CALIB_GEN", 512);
59+
let rank = env_usize("RANK", 8);
60+
let steps = env_usize("STEPS", 120);
61+
let lr: f32 = std::env::var("LR")
62+
.ok()
63+
.and_then(|v| v.parse().ok())
64+
.unwrap_or(0.02);
65+
let k = env_usize("K", 1);
66+
let weight_decay = env_f32("WD", 0.0);
67+
let kl = std::env::var("HI_OBJECTIVE")
68+
.map(|v| v.eq_ignore_ascii_case("kl"))
69+
.unwrap_or(false);
70+
let topk = env_usize("HI_TOPK", 16);
71+
// HI_POSITIONS: comma list of {eh,q,k,v,o,gate,up,down}. Default = eh only.
72+
let positions: Vec<MtpLoraPos> = std::env::var("HI_POSITIONS")
73+
.ok()
74+
.map(|s| {
75+
s.split(',')
76+
.filter_map(|p| MtpLoraPos::parse(p))
77+
.collect::<Vec<_>>()
78+
})
79+
.filter(|v| !v.is_empty())
80+
.unwrap_or_else(|| vec![MtpLoraPos::Eh]);
81+
82+
// ── config-driven dims (mirrors bench_qwen35_mtp_realprompt) ──
83+
let cfg_path = std::path::Path::new(&model_id).join("config.json");
84+
let cfg: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cfg_path)?)?;
85+
let tc = cfg.get("text_config").unwrap_or(&cfg);
86+
let gi = |k: &str| tc.get(k).and_then(|v| v.as_u64()).map(|v| v as usize);
87+
let gf = |k: &str| tc.get(k).and_then(|v| v.as_f64());
88+
let rp = tc.get("rope_parameters");
89+
let grp = |k: &str| rp.and_then(|r| r.get(k)).and_then(|v| v.as_f64());
90+
let hidden = gi("hidden_size").ok_or_else(|| anyhow!("config missing hidden_size"))?;
91+
let head_dim = gi("head_dim").unwrap_or(128);
92+
let partial = gf("partial_rotary_factor")
93+
.or_else(|| grp("partial_rotary_factor"))
94+
.unwrap_or(0.25);
95+
let n_experts = gi("num_experts").unwrap_or(0);
96+
let dims = Qwen35MtpDims {
97+
hidden_size: hidden,
98+
num_attention_heads: gi("num_attention_heads").unwrap_or(32),
99+
num_key_value_heads: gi("num_key_value_heads").unwrap_or(8),
100+
head_dim,
101+
rope_theta: gf("rope_theta")
102+
.or_else(|| grp("rope_theta"))
103+
.unwrap_or(1.0e7) as f32,
104+
rope_dim: (head_dim as f64 * partial).round() as usize,
105+
rms_norm_eps: gf("rms_norm_eps").unwrap_or(1e-6) as f32,
106+
attn_output_gate: true,
107+
};
108+
let mlp_cfg = if n_experts > 0 {
109+
MtpMlpConfig::Moe(MtpMoeConfig {
110+
num_experts: n_experts as i32,
111+
num_experts_per_tok: gi("num_experts_per_tok").unwrap_or(8) as i32,
112+
moe_intermediate_size: gi("moe_intermediate_size").unwrap_or(512),
113+
shared_expert_intermediate_size: gi("shared_expert_intermediate_size").unwrap_or(512),
114+
norm_topk_prob: true,
115+
})
116+
} else {
117+
MtpMlpConfig::Dense {
118+
intermediate_size: gi("intermediate_size")
119+
.ok_or_else(|| anyhow!("dense config missing intermediate_size"))?,
120+
}
121+
};
122+
println!(
123+
"=== head-internal MTP C4 PoC ===\nmodel: {model_id}\nconfig: hidden={hidden} heads={} kv={} head_dim={head_dim} mlp={}",
124+
dims.num_attention_heads,
125+
dims.num_key_value_heads,
126+
if n_experts > 0 { "MoE" } else { "Dense" }
127+
);
128+
let pos_str = positions
129+
.iter()
130+
.map(|p| p.as_str())
131+
.collect::<Vec<_>>()
132+
.join("+");
133+
println!(
134+
"calib_gen={calib_gen} rank={rank} steps={steps} lr={lr} wd={weight_decay} K={k} positions={pos_str} objective={} topk={topk}",
135+
if kl { "kl" } else { "ce" }
136+
);
137+
138+
let mut backend = MlxBackend::load(&model_id)?;
139+
140+
// Diverse calib corpus so the LoRA learns a generalizable correction (not
141+
// one prompt's tokens). The train/held-out split is a sequential 80/20 tail,
142+
// so with N prompts (rows contiguous per prompt) the held-out 20% is the
143+
// LAST ~N/5 *entire* prompts — a genuine CROSS-PROMPT generalization test
144+
// (no intra-prompt leakage). Keep this list large + topically diverse.
145+
// Encoded before the mutable qwen borrow.
146+
const TOPICS: &[&str] = &[
147+
"<|im_start|>user\nExplain how a hash map achieves average O(1) lookup and what causes collisions.<|im_end|>\n<|im_start|>assistant\n",
148+
"<|im_start|>user\nDescribe how photosynthesis converts sunlight into chemical energy.<|im_end|>\n<|im_start|>assistant\n",
149+
"<|im_start|>user\nWrite a short story about a lighthouse keeper who finds a message in a bottle.<|im_end|>\n<|im_start|>assistant\n",
150+
"<|im_start|>user\nExplain the difference between TCP and UDP and when each is preferred.<|im_end|>\n<|im_start|>assistant\n",
151+
"<|im_start|>user\nHow does a transformer use self-attention to process sequences?<|im_end|>\n<|im_start|>assistant\n",
152+
"<|im_start|>user\nSummarize the causes of the fall of the Roman Empire.<|im_end|>\n<|im_start|>assistant\n",
153+
"<|im_start|>user\nExplain compound interest and its effect on long-term savings.<|im_end|>\n<|im_start|>assistant\n",
154+
"<|im_start|>user\nDescribe how vaccines train the immune system to recognize pathogens.<|im_end|>\n<|im_start|>assistant\n",
155+
"<|im_start|>user\nWrite a Python function that merges two sorted lists into one sorted list.<|im_end|>\n<|im_start|>assistant\n",
156+
"<|im_start|>user\nExplain what a database index is and the tradeoffs of adding one.<|im_end|>\n<|im_start|>assistant\n",
157+
"<|im_start|>user\nDescribe the water cycle and the role of evaporation and condensation.<|im_end|>\n<|im_start|>assistant\n",
158+
"<|im_start|>user\nWhat is the difference between supervised and unsupervised learning?<|im_end|>\n<|im_start|>assistant\n",
159+
"<|im_start|>user\nExplain how garbage collection works in a managed runtime.<|im_end|>\n<|im_start|>assistant\n",
160+
"<|im_start|>user\nDescribe the causes and effects of inflation in an economy.<|im_end|>\n<|im_start|>assistant\n",
161+
"<|im_start|>user\nWrite a haiku about the changing of the seasons.<|im_end|>\n<|im_start|>assistant\n",
162+
"<|im_start|>user\nExplain how DNS resolves a domain name to an IP address.<|im_end|>\n<|im_start|>assistant\n",
163+
"<|im_start|>user\nDescribe how a four-stroke internal combustion engine works.<|im_end|>\n<|im_start|>assistant\n",
164+
"<|im_start|>user\nExplain the concept of recursion with a simple example.<|im_end|>\n<|im_start|>assistant\n",
165+
"<|im_start|>user\nSummarize the key ideas of plate tectonics.<|im_end|>\n<|im_start|>assistant\n",
166+
"<|im_start|>user\nWhat are the main differences between HTTP/1.1 and HTTP/2?<|im_end|>\n<|im_start|>assistant\n",
167+
"<|im_start|>user\nExplain how a binary search tree keeps lookups logarithmic.<|im_end|>\n<|im_start|>assistant\n",
168+
"<|im_start|>user\nDescribe the role of mitochondria in a cell.<|im_end|>\n<|im_start|>assistant\n",
169+
"<|im_start|>user\nExplain the tradeoffs between optimistic and pessimistic locking.<|im_end|>\n<|im_start|>assistant\n",
170+
"<|im_start|>user\nWrite a brief explanation of how rainbows form.<|im_end|>\n<|im_start|>assistant\n",
171+
];
172+
let calib_prompts: Vec<Vec<u32>> = TOPICS
173+
.iter()
174+
.map(|t| backend.encode(t))
175+
.collect::<Result<_>>()?;
176+
177+
let qwen = backend
178+
.as_qwen35_mut()
179+
.ok_or_else(|| anyhow!("not Qwen3.5 family"))?;
180+
let block = load_block_from_hf(
181+
&hf_path,
182+
dims,
183+
mlp_cfg,
184+
MtpLoadQuant::Affine4 { group_size: 64 },
185+
)?;
186+
qwen.enable_qwen35_mtp(block)?;
187+
if !qwen.qwen35_mtp_enabled() {
188+
return Err(anyhow!("mtp enable failed"));
189+
}
190+
println!("trunk + mtp head loaded.");
191+
192+
// Collect k=0 (embed, h_pre, trunk-target) calib over a greedy decode.
193+
qwen.qwen35_enable_mtp_hi_calib()?;
194+
let per = (calib_gen / calib_prompts.len()).max(8);
195+
for (i, cp) in calib_prompts.iter().enumerate() {
196+
let seq: u64 = 5000 + i as u64;
197+
let (mut last, _) = qwen.prefill(seq, cp)?;
198+
let mut emitted = 0usize;
199+
while emitted < per {
200+
let out = qwen.qwen35_mtp_step(seq, last, k, 0.0, 1.0)?;
201+
emitted += out.committed.len();
202+
last = *out.committed.last().expect("commit non-empty");
203+
}
204+
qwen.remove_seq(seq)?;
205+
eprintln!(
206+
" calib prompt {}/{}: ~{emitted} cycles",
207+
i + 1,
208+
calib_prompts.len()
209+
);
210+
}
211+
212+
// Train the head-internal LoRA(s) + report the gate.
213+
let cfg = HiTrainCfg {
214+
rank,
215+
steps,
216+
lr,
217+
weight_decay,
218+
positions,
219+
kl,
220+
topk,
221+
seed: env_usize("SEED", 1234) as u64,
222+
};
223+
// `after` is the HONEST stable tail-mean (not the selection-biased max — see
224+
// train_mtp_headinternal). 1σ of the held-out estimate ≈ sqrt(p(1-p)/n_ho);
225+
// require Δ ≥ ~2σ to claim a real (non-noise) cross-prompt lift. With
226+
// n_ho≈90 and p≈0.75, 2σ ≈ 0.09 → the bar is deliberately strict.
227+
let (before, after) = qwen.qwen35_train_mtp_headinternal(cfg)?;
228+
let delta = after - before;
229+
println!("\n════════════════════════════════════════════");
230+
println!(" HELD-OUT accept (stable tail-mean): {before:.3} → {after:.3} (Δ {delta:+.3})");
231+
if delta >= 0.05 {
232+
println!(
233+
" ✓ GENERALIZES — stable cross-prompt lift clear of noise. Worth productionizing."
234+
);
235+
} else if delta > 0.0 {
236+
println!(
237+
" ~ MARGINAL — within ~1-2σ of held-out noise; not a reliable lift (see σ line above)."
238+
);
239+
} else {
240+
println!(" ✗ NO LIFT — tail-mean ≤ baseline (overfit / collapse). Fold, like lm_head-C4.");
241+
}
242+
println!("════════════════════════════════════════════");
243+
Ok(())
244+
}

0 commit comments

Comments
 (0)