Instructions to use Duke-CEI-SVD/traj-mc with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Duke-CEI-SVD/traj-mc with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Duke-CEI-SVD/traj-mc", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download code/compress/compress_baselines.py from Duke-CEI-SVD/traj-mc: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/Duke-CEI-SVD/traj-mc/resolve/main/code/compress/compress_baselines.py
- Command line
-
hf download hf://Duke-CEI-SVD/traj-mc/code/compress/compress_baselines.py
-
curl -L -o compress_baselines.py https://huggingface.co/Duke-CEI-SVD/traj-mc/resolve/main/code/compress/compress_baselines.py
19.9 kB
| """compress_baselines.py -- pruning / quantization baselines on LLaDA-8B-Base. | |
| Methods: wanda, sparsegpt (pruning), gptq, awq (weight-only FAKE quant: quantize -> | |
| dequantize -> fp16). Each nn.Linear -> a dense bf16 weight saved as {prefix}_weight.pt | |
| (loaded by eval/llada_harness.py replace_with_decoded_weights -- auto-detected since no | |
| *_A.pt is present). Uses the SAME calib .pt as the SVD arms (base/ours/asvd) so all | |
| methods are calibration-matched. X^T X (second moment of inputs) is collected in one | |
| forward pass, GPU-resident and memory-binned, offloaded to CPU per bin (like compress.py). | |
| Pruning sparsity default 0.23 == keep 0.77 == the model_ratio 0.8 retention of the SVD arms. | |
| """ | |
| import os, sys, time, argparse | |
| import torch | |
| import torch.nn as nn | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| import common as C | |
| # ββ calib X^T X collection (GPU-resident, binned) β same as compress.py ββββββββββ | |
| # NB Traj-MC fix: with the noised (ours) calib ~47% of positions are MASK_ID, whose | |
| # input embedding is a single degenerate vector. Accumulating those rows into X^T X | |
| # injects a giant rank-1 spike (breaks the cholesky-inverse error comp in | |
| # gptq/sparsegpt) and washes out the per-channel outlier signal (wanda/awq under- | |
| # respond). The calib DATA is the full trajectory (t~U[0,1], t=1 full-mask kept) β | |
| # we only restrict the Monte-Carlo *statistic* to the real-token rows, i.e. the | |
| # activations the compressed layer actually has to preserve. mask_id=None (clean | |
| # base calib, 0% mask) is a no-op, so the base arm is byte-for-byte unchanged. | |
| def collect_xtx_cpu(model, input_ids, target_mods, device, log_prefix="", mask_id=None): | |
| """Legacy path: per-sample D2H copy + CPU accumulate (~33s/sample on A5000). | |
| Kept only for --xtx_cpu reproduction; collect_xtx_gpu computes the identical | |
| statistic on GPU.""" | |
| cov, ntok = {}, {} | |
| _keep = {"m": None} # per-sample real-token row mask, shape [L], set before each fwd | |
| def make_hook(name, in_dim): | |
| def hook(module, inp, out): | |
| x = inp[0].detach().reshape(-1, in_dim).float() | |
| k = _keep["m"] | |
| if k is not None and k.numel() == x.shape[0]: | |
| x = x[k] # drop MASK-position rows | |
| if x.shape[0] == 0: # fully-masked sample -> contributes nothing | |
| return | |
| if name not in cov: | |
| cov[name] = torch.zeros(in_dim, in_dim, dtype=torch.float32) # CPU η΄―ε ,GPU εͺζΎζ¨‘ε | |
| ntok[name] = 0 | |
| cov[name] += (x.t() @ x).cpu() | |
| ntok[name] += x.shape[0] | |
| return hook | |
| handles = [m.register_forward_hook(make_hook(n, m.in_features)) | |
| for n, m in target_mods.items()] | |
| t0 = time.time() | |
| with torch.no_grad(): | |
| for i in range(input_ids.shape[0]): | |
| ids = input_ids[i:i + 1].to(device) | |
| _keep["m"] = None if mask_id is None else (ids.reshape(-1) != mask_id) | |
| model(ids) | |
| torch.cuda.empty_cache() | |
| if i % 200 == 0: | |
| print(f" {log_prefix}collect {i}/{input_ids.shape[0]} ({time.time()-t0:.0f}s)", flush=True) | |
| for h in handles: | |
| h.remove() | |
| out = {n: (cov[n] / ntok[n]) for n in list(cov)} | |
| for n in list(cov): | |
| cov[n] = None | |
| torch.cuda.empty_cache() | |
| return out | |
| def collect_xtx_gpu(model, input_ids, target_mods, device, log_prefix="", mask_id=None): | |
| """Same statistic as collect_xtx_cpu (incl. the real-token row restriction), but | |
| accumulated ON GPU via addmm_ (no per-forward D2H copy), offloaded to CPU once at | |
| the end β ported from compress.py. Caller must ensure this group's total XtX | |
| (sum of in_features^2 * 4 bytes) fits on the GPU beside the model (use plan_bins).""" | |
| cov, ntok = {}, {} | |
| _keep = {"m": None} # per-sample real-token row mask, shape [L], set before each fwd | |
| def make_hook(name, in_dim): | |
| def hook(module, inp, out): | |
| x = inp[0].detach().reshape(-1, in_dim).float() | |
| k = _keep["m"] | |
| if k is not None and k.numel() == x.shape[0]: | |
| x = x[k] # drop MASK-position rows | |
| if x.shape[0] == 0: # fully-masked sample -> contributes nothing | |
| return | |
| if name not in cov: | |
| cov[name] = torch.zeros(in_dim, in_dim, dtype=torch.float32, device=device) | |
| ntok[name] = 0 | |
| cov[name].addmm_(x.t(), x) # cov += x^T x, in-place on GPU | |
| ntok[name] += x.shape[0] | |
| return hook | |
| handles = [m.register_forward_hook(make_hook(n, m.in_features)) | |
| for n, m in target_mods.items()] | |
| t0 = time.time() | |
| with torch.no_grad(): | |
| for i in range(input_ids.shape[0]): | |
| ids = input_ids[i:i + 1].to(device) | |
| _keep["m"] = None if mask_id is None else (ids.reshape(-1) != mask_id) | |
| model(ids) | |
| if i % 200 == 0: | |
| print(f" {log_prefix}collect {i}/{input_ids.shape[0]} ({time.time()-t0:.0f}s)", flush=True) | |
| for h in handles: | |
| h.remove() | |
| out = {} | |
| for n in list(cov): | |
| out[n] = (cov[n] / ntok[n]).cpu() | |
| cov[n] = None | |
| del cov | |
| torch.cuda.empty_cache() | |
| return out | |
| def plan_bins(targets, budget_bytes): | |
| bins, cur, cur_b = [], {}, 0 | |
| for name, mod in targets: | |
| b = mod.in_features * mod.in_features * 4 | |
| if cur and cur_b + b > budget_bytes: | |
| bins.append(cur); cur, cur_b = {}, 0 | |
| cur[name] = mod; cur_b += b | |
| if cur: | |
| bins.append(cur) | |
| return bins | |
| # ββ methods (each returns a dense W' of the SAME shape) ββββββββββββββββββββββββββ | |
| def wanda_prune(W, XtX, sparsity, prune_n=2, prune_m=4): | |
| """Wanda: importance = |W| * ||X_col||. | |
| prune_n:prune_m = N:M STRUCTURED sparsity (default 2:4 = zero the 2 lowest of every | |
| 4 consecutive input cols -> 50% sparse, hardware-accelerated). prune_n=0 falls back | |
| to UNSTRUCTURED at `sparsity` (no real speedup).""" | |
| scaler = torch.sqrt(torch.diag(XtX).clamp(min=0)).reshape(1, -1) # [1, in] | |
| metric = W.abs() * scaler | |
| W = W.clone() | |
| if prune_n > 0: | |
| out, inc = W.shape | |
| g = inc // prune_m | |
| mt = metric[:, :g * prune_m].reshape(out, g, prune_m) | |
| idx = mt.topk(prune_n, dim=2, largest=False).indices # 2 lowest per group of 4 | |
| mask = torch.zeros_like(mt, dtype=torch.bool).scatter_(2, idx, True).reshape(out, g * prune_m) | |
| Wv = W[:, :g * prune_m] | |
| Wv[mask] = 0.0 | |
| W[:, :g * prune_m] = Wv | |
| else: | |
| k = int(W.shape[1] * sparsity) | |
| if k > 0: | |
| idx = torch.topk(metric, k, dim=1, largest=False).indices | |
| W.scatter_(1, idx, 0.0) | |
| return W | |
| def sparsegpt_prune(W, H, sparsity, blocksize=128, percdamp=0.01, prune_n=2, prune_m=4): | |
| """Faithful port of SparseGPT fasterprune (Sink-Aware-Pruning/lib/sparsegpt.py). | |
| H = X X^T (== our XtX). Column-wise pruning with Hessian error compensation. | |
| prune_n:prune_m = N:M STRUCTURED (default 2:4, hardware-accelerated). prune_n=0 -> | |
| UNSTRUCTURED at `sparsity`.""" | |
| W = W.clone().float() | |
| H = H.clone().float() | |
| cols = W.shape[1] | |
| dead = torch.diag(H) == 0 | |
| H[dead, dead] = 1.0 | |
| W[:, dead] = 0.0 | |
| damp = percdamp * torch.mean(torch.diag(H)) | |
| diag = torch.arange(cols, device=W.device) | |
| H[diag, diag] += damp | |
| H = torch.linalg.cholesky(H) | |
| H = torch.cholesky_inverse(H) | |
| H = torch.linalg.cholesky(H, upper=True) | |
| Hinv = H | |
| for i1 in range(0, cols, blocksize): | |
| i2 = min(i1 + blocksize, cols) | |
| count = i2 - i1 | |
| W1 = W[:, i1:i2].clone() | |
| Q1 = torch.zeros_like(W1) | |
| Err1 = torch.zeros_like(W1) | |
| Hinv1 = Hinv[i1:i2, i1:i2] | |
| mask1 = torch.zeros_like(W1, dtype=torch.bool) | |
| if prune_n == 0: # unstructured: one threshold over the block | |
| tmp = W1 ** 2 / (torch.diag(Hinv1).reshape((1, -1))) ** 2 | |
| thresh = torch.sort(tmp.flatten())[0][int(tmp.numel() * sparsity)] | |
| mask1 = tmp <= thresh | |
| for i in range(count): | |
| w = W1[:, i] | |
| d = Hinv1[i, i] | |
| if prune_n != 0 and i % prune_m == 0: # N:M: pick prune_n lowest of the next prune_m cols | |
| tmp = W1[:, i:i + prune_m] ** 2 \ | |
| / (torch.diag(Hinv1)[i:i + prune_m].reshape((1, -1))) ** 2 | |
| mask1.scatter_(1, i + torch.topk(tmp, prune_n, dim=1, largest=False).indices, True) | |
| q = w.clone() | |
| q[mask1[:, i]] = 0.0 | |
| Q1[:, i] = q | |
| err1 = (w - q) / d | |
| W1[:, i:] -= err1.unsqueeze(1).matmul(Hinv1[i, i:].unsqueeze(0)) | |
| Err1[:, i] = err1 | |
| W[:, i1:i2] = Q1 | |
| W[:, i2:] -= Err1.matmul(Hinv[i1:i2, i2:]) | |
| return W | |
| # ββ GPTQ (IST-DASLab/gptq) weight-only fake-quant, asym per-row per-group ββββββββ | |
| def _gptq_find_params(W, bits, sym=False): | |
| maxq = 2 ** bits - 1 | |
| x = W.flatten(1) | |
| zeros = torch.zeros(x.shape[0], device=x.device) | |
| xmin = torch.minimum(x.min(1)[0], zeros) | |
| xmax = torch.maximum(x.max(1)[0], zeros) | |
| if sym: | |
| xmax = torch.maximum(xmin.abs(), xmax) | |
| neg = xmin < 0 | |
| xmin[neg] = -xmax[neg] | |
| deg = (xmin == 0) & (xmax == 0) | |
| xmin[deg], xmax[deg] = -1, 1 | |
| scale = (xmax - xmin) / maxq | |
| zero = torch.full_like(scale, (maxq + 1) / 2) if sym else torch.round(-xmin / scale) | |
| return scale.unsqueeze(1), zero.unsqueeze(1), maxq | |
| def _gptq_quant(x, scale, zero, maxq): | |
| q = torch.clamp(torch.round(x / scale) + zero, 0, maxq) | |
| return scale * (q - zero) | |
| def gptq_quantize(W, H, bits=3, groupsize=128, percdamp=0.01, blocksize=128, sym=False, actorder=False): | |
| orig_dtype = W.dtype | |
| W = W.clone().float() | |
| H = H.clone().float() | |
| rows, columns = W.shape | |
| dev = W.device | |
| dead = torch.diag(H) == 0 | |
| H[dead, dead] = 1 | |
| W[:, dead] = 0 | |
| invperm = None | |
| if actorder: | |
| perm = torch.argsort(torch.diag(H), descending=True) | |
| W = W[:, perm]; H = H[perm][:, perm] | |
| invperm = torch.argsort(perm) | |
| if groupsize == -1: | |
| scale, zero, maxq = _gptq_find_params(W, bits, sym) | |
| else: | |
| maxq = 2 ** bits - 1 | |
| scale = zero = None | |
| Q = torch.zeros_like(W) | |
| damp = percdamp * torch.mean(torch.diag(H)) | |
| diag = torch.arange(columns, device=dev) | |
| H[diag, diag] += damp | |
| H = torch.linalg.cholesky(H) | |
| H = torch.cholesky_inverse(H) | |
| Hinv = torch.linalg.cholesky(H, upper=True) | |
| for i1 in range(0, columns, blocksize): | |
| i2 = min(i1 + blocksize, columns) | |
| count = i2 - i1 | |
| W1 = W[:, i1:i2].clone() | |
| Q1 = torch.zeros_like(W1) | |
| Err1 = torch.zeros_like(W1) | |
| Hinv1 = Hinv[i1:i2, i1:i2] | |
| for i in range(count): | |
| w = W1[:, i] | |
| d = Hinv1[i, i] | |
| if groupsize != -1 and (i1 + i) % groupsize == 0: | |
| scale, zero, _ = _gptq_find_params(W[:, (i1 + i):(i1 + i + groupsize)], bits, sym) | |
| q = _gptq_quant(w.unsqueeze(1), scale, zero, maxq).flatten() | |
| Q1[:, i] = q | |
| err1 = (w - q) / d | |
| W1[:, i:] -= err1.unsqueeze(1).matmul(Hinv1[i, i:].unsqueeze(0)) | |
| Err1[:, i] = err1 | |
| Q[:, i1:i2] = Q1 | |
| W[:, i2:] -= Err1.matmul(Hinv[i1:i2, i2:]) | |
| if actorder: | |
| Q = Q[:, invperm] | |
| return Q.to(orig_dtype) | |
| # ββ AWQ (mit-han-lab/llm-awq) activation-aware fake-quant, asym group-wise ββββββββ | |
| def _awq_pseudo_quant(w, n_bit=4, group=128, zero_point=True): | |
| shape = w.shape | |
| if group > 0: | |
| assert shape[-1] % group == 0 | |
| w = w.reshape(-1, group) | |
| if zero_point: | |
| max_val = w.amax(dim=1, keepdim=True) | |
| min_val = w.amin(dim=1, keepdim=True) | |
| max_int, min_int = 2 ** n_bit - 1, 0 | |
| scales = (max_val - min_val).clamp(min=1e-5) / max_int | |
| zeros = (-torch.round(min_val / scales)).clamp_(min_int, max_int) | |
| w = (torch.clamp(torch.round(w / scales) + zeros, min_int, max_int) - zeros) * scales | |
| else: | |
| max_val = w.abs().amax(dim=1, keepdim=True).clamp(min=1e-5) | |
| max_int, min_int = 2 ** (n_bit - 1) - 1, -(2 ** (n_bit - 1)) | |
| scales = max_val / max_int | |
| w = torch.clamp(torch.round(w / scales), min_int, max_int) * scales | |
| return w.reshape(shape) | |
| def awq_quantize(W, act_scale, bits=4, groupsize=128, n_grid=20, zero_point=True, H=None, do_clip=True): | |
| orig_dtype = W.dtype | |
| W = W.clone().float() | |
| act_scale = act_scale.float().to(W.device) | |
| def loss(Wq): | |
| d = W - Wq | |
| if H is not None: | |
| return torch.einsum('oi,ij,oj->', d, H.float(), d).item() | |
| return (d.pow(2) * act_scale.pow(2).unsqueeze(0)).sum().item() | |
| best_loss, best_s = float('inf'), torch.ones_like(act_scale) | |
| for i in range(n_grid): | |
| ratio = i / n_grid | |
| s = act_scale.pow(ratio).clamp(min=1e-4) | |
| s = s / (s.max() * s.min()).sqrt() | |
| Wq = _awq_pseudo_quant(W * s.unsqueeze(0), bits, groupsize, zero_point) / s.unsqueeze(0) | |
| L = loss(Wq) | |
| if L < best_loss: | |
| best_loss, best_s = L, s | |
| Ws = W * best_s.unsqueeze(0) | |
| if do_clip: | |
| g = groupsize if groupsize > 0 else Ws.shape[1] | |
| wg = Ws.reshape(Ws.shape[0], -1, g) | |
| org_max = wg.abs().amax(dim=-1, keepdim=True) | |
| best_max = org_max.clone() | |
| best_err = torch.full_like(org_max, float('inf')) | |
| for i in range(int(0.5 * n_grid)): | |
| mx = org_max * (1 - i / n_grid) | |
| q = _awq_pseudo_quant(torch.clamp(wg, -mx, mx).reshape(-1, g), bits, g, zero_point).reshape(wg.shape) | |
| err = (q - wg).pow(2).mean(dim=-1, keepdim=True) | |
| better = err < best_err | |
| best_err = torch.where(better, err, best_err) | |
| best_max = torch.where(better, mx, best_max) | |
| Ws = torch.clamp(wg, -best_max, best_max).reshape(Ws.shape) | |
| Wq = _awq_pseudo_quant(Ws, bits, groupsize, zero_point) / best_s.unsqueeze(0) | |
| return Wq.to(orig_dtype) | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--calib", required=True) | |
| ap.add_argument("--method", required=True, choices=["wanda", "sparsegpt", "gptq", "awq"]) | |
| ap.add_argument("--sparsity", type=float, default=0.23, | |
| help="prune fraction (0.23 == keep 0.77 == model_ratio 0.8 retention)") | |
| ap.add_argument("--bits", type=int, default=4) | |
| ap.add_argument("--prune_n", type=int, default=2, | |
| help="N:M structured sparsity for wanda/sparsegpt (default 2:4, " | |
| "hardware-accelerated). Set 0 for unstructured at --sparsity.") | |
| ap.add_argument("--prune_m", type=int, default=4) | |
| ap.add_argument("--groupsize", type=int, default=128) | |
| ap.add_argument("--layer_type", choices=["all", "attn", "mlp"], default="all") | |
| ap.add_argument("--model_path", default=C.DEFAULT_MODEL_PATH) | |
| ap.add_argument("--save_path", required=True) | |
| ap.add_argument("--nsamples", type=int, default=0) | |
| ap.add_argument("--xtx_budget_gb", type=float, default=0.0, | |
| help="max GB of XtX kept on GPU per collection pass. 0 = AUTO: " | |
| "free GPU memory after the model is resident minus headroom, " | |
| "so the same command runs on a 24GB or a 48GB card (only the " | |
| "number of passes changes -- binning never touches math).") | |
| ap.add_argument("--xtx_headroom_gb", type=float, default=6.0) | |
| ap.add_argument("--xtx_cpu", action="store_true", | |
| help="legacy per-sample CPU accumulation (slow; repro only)") | |
| args = ap.parse_args() | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| os.makedirs(args.save_path, exist_ok=True) | |
| t0 = time.time() | |
| blob = torch.load(args.calib, map_location="cpu") | |
| input_ids = blob["input_ids"] | |
| if args.nsamples and args.nsamples < input_ids.shape[0]: | |
| input_ids = input_ids[: args.nsamples] | |
| arm = blob.get("arm", "unknown") | |
| print(f"[baselines] method={args.method} calib={os.path.basename(args.calib)} " | |
| f"arm={arm} N={input_ids.shape[0]} sparsity={args.sparsity} bits={args.bits}", flush=True) | |
| model, tok = C.load_model(args.model_path, device=device) | |
| targets = list(C.iter_target_linears(model, args.layer_type)) | |
| print(f"[baselines] target linears ({args.layer_type}) = {len(targets)}", flush=True) | |
| if args.xtx_cpu: | |
| cov = collect_xtx_cpu(model, input_ids, dict(targets), device, mask_id=C.MASK_ID) | |
| else: | |
| budget_gb = args.xtx_budget_gb | |
| if budget_gb <= 0: | |
| if device == "cuda": | |
| free_b, total_b = torch.cuda.mem_get_info() | |
| budget_gb = max(2.0, free_b / 1024 ** 3 - args.xtx_headroom_gb) | |
| print(f"[baselines] AUTO xtx budget: free={free_b/1024**3:.1f}GB " | |
| f"total={total_b/1024**3:.1f}GB headroom={args.xtx_headroom_gb}GB " | |
| f"-> budget={budget_gb:.1f}GB", flush=True) | |
| else: | |
| budget_gb = 8.0 | |
| bins = plan_bins(targets, budget_gb * 1024 ** 3) | |
| print(f"[baselines] {len(targets)} target linears -> {len(bins)} XtX pass(es) " | |
| f"(budget {budget_gb:.1f}GB/pass)", flush=True) | |
| cov = {} | |
| for bi, b in enumerate(bins): | |
| gb = sum(m.in_features ** 2 * 4 for m in b.values()) / 1024 ** 3 | |
| print(f"[baselines] XtX pass {bi+1}/{len(bins)}: {len(b)} layers, {gb:.1f}GB", flush=True) | |
| cov.update(collect_xtx_gpu(model, input_ids, b, device, | |
| log_prefix=f"p{bi+1} ", mask_id=C.MASK_ID)) | |
| print(f"[baselines] XtX collected for {len(cov)} layers ({time.time()-t0:.0f}s)", flush=True) | |
| n_done = 0 | |
| for name, mod in targets: | |
| if name not in cov: | |
| continue | |
| W = mod.weight.data.cpu().float().to(device) | |
| H = cov[name].to(device) | |
| if args.method == "wanda": | |
| Wq = wanda_prune(W, H, args.sparsity, args.prune_n, args.prune_m) | |
| elif args.method == "sparsegpt": | |
| Wq = sparsegpt_prune(W, H, args.sparsity, prune_n=args.prune_n, prune_m=args.prune_m) | |
| elif args.method == "gptq": | |
| Wq = gptq_quantize(W, H, args.bits, args.groupsize) | |
| elif args.method == "awq": | |
| act = torch.sqrt(torch.diag(H).clamp(min=0)) # per-channel RMS ~ mean|x| proxy | |
| Wq = awq_quantize(W, act, args.bits, args.groupsize, H=H) | |
| torch.save(Wq.to(torch.bfloat16).cpu(), | |
| os.path.join(args.save_path, f"{name.replace('.', '_')}_weight.pt")) | |
| del W, H, Wq | |
| torch.cuda.empty_cache() | |
| n_done += 1 | |
| if n_done % 30 == 0: | |
| print(f" [{n_done}/{len(targets)}] {name}", flush=True) | |
| _structured = (args.method in ("wanda", "sparsegpt") and args.prune_n > 0) | |
| summary = {"method": args.method, "arm": arm, "sparsity": args.sparsity, | |
| "bits": args.bits, "groupsize": args.groupsize, | |
| "structured": f"{args.prune_n}:{args.prune_m}" if _structured else "unstructured", | |
| "real_sparsity": (args.prune_n / args.prune_m) if _structured else args.sparsity, | |
| "layer_type": args.layer_type, "n_layers": n_done, | |
| "calib_file": os.path.basename(args.calib), "git_hash": C.git_hash(), | |
| "wall_clock_sec": time.time() - t0, "save_path": args.save_path} | |
| C.dump_json(summary, os.path.join(args.save_path, "compression_summary.json")) | |
| print(f"[baselines] done: {n_done} layers -> {args.save_path} ({(time.time()-t0)/60:.1f}min)", flush=True) | |
| if __name__ == "__main__": | |
| main() | |