bitsandbytes-cpu-fork 0.50.2.dev4__py3-none-win_amd64.whl → 0.50.2.dev5__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bitsandbytes/__init__.py CHANGED
@@ -75,7 +75,7 @@ __pdoc__ = {
75
75
  "optim.optimizer.MockArgs": False,
76
76
  }
77
77
 
78
- __version__ = "0.50.2.dev4"
78
+ __version__ = "0.50.2.dev5"
79
79
 
80
80
  # 挂载 gdn_cpu(GDN 融合内核 / patch_transformers 入口),让 bnb.gdn_cpu 可直接访问
81
81
  from . import gdn_cpu # noqa: E402,F401
@@ -253,15 +253,29 @@ if not isinstance(lib, ErrorHandlerMockBNBNativeLibrary):
253
253
  if has_avx512bf16():
254
254
  gemm_4bit_forward_kernel = None
255
255
  try:
256
- from kernels import get_kernel
256
+ import importlib.util
257
+
258
+ if importlib.util.find_spec("kernels") is None:
259
+ # `kernels` is an OPTIONAL HuggingFace hub-kernel package. The fused native
260
+ # CPU kernel below is the default and needs no extra dependency, so a
261
+ # missing optional package is not a warning -- otherwise every CPU-only
262
+ # `import bitsandbytes` printed a scary line on AVX512-BF16 machines.
263
+ logger.debug(
264
+ "optional package 'kernels' not installed; using the native CPU "
265
+ "gemv_4bit kernel"
266
+ )
267
+ else:
268
+ from kernels import get_kernel
257
269
 
258
- gemm_4bit_forward_kernel = get_kernel(
259
- "kernels-community/quantization-bitsandbytes", version=1
260
- ).gemm_4bit_forward
270
+ gemm_4bit_forward_kernel = get_kernel(
271
+ "kernels-community/quantization-bitsandbytes", version=1
272
+ ).gemm_4bit_forward
261
273
  except Exception as exc: # pragma: no cover - best effort fallback
262
274
  gemm_4bit_forward_kernel = None
263
275
  logger.warning(
264
- "Failed to load CPU gemm_4bit_forward from kernels-community: %s. Please make sure you already `pip install kernels` and the kernels >= 0.11.1",
276
+ "Failed to load CPU gemm_4bit_forward from kernels-community: %s. "
277
+ "The optional 'kernels' package is installed but the hub kernel could not "
278
+ "be loaded; falling back to the native CPU kernel.",
265
279
  exc,
266
280
  )
267
281
 
@@ -9,6 +9,14 @@ Windows 上也会直接导入失败。已确认这段 docstring 内除这一处
9
9
  也没有 `\u`/`\x` 这类加了 r 会改变含义的转义,所以加 r 前缀不改变任何现有语义。
10
10
  (是 Android/ARM64 上跑测试时暴露的: 本机 Python 3.11 只给警告,3.14 明确提示。)
11
11
 
12
+ 支持平台(2026-10 起):
13
+ - **Windows**:盘符检测(ctypes,无需额外依赖),缓存默认放在脚本同目录 `.flash_cache/`;
14
+ - **Linux**:psutil + /proc/mounts,缓存默认放 `$XDG_CACHE_HOME/bnb_flash_cache`(或
15
+ `~/.cache/bnb_flash_cache`),按 `/sys/block/*/queue/rotational` 识别 SSD/HDD;
16
+ - **WSL**:**支持**。只使用 WSL 发行版自己的 ext4(通常是 `/`),9P 宿主盘
17
+ (`/mnt/c`、`/mnt/d` …)、网络盘、只读挂载和 tmpfs/ramfs 一律拒绝(9P 上高负载
18
+ 卸载曾扰乱宿主 MFT)。显式 `--flash_paths` 配到这些位置会直接报错而不是静默忽略。
19
+
12
20
  目标:
13
21
  - 对抗 Windows 虚拟内存在 AI 训练时频繁擦写导致硬盘活动时间 100% 的问题;
14
22
  - 异步写盘线程(queue.Queue),不阻塞训练循环;
@@ -64,7 +72,9 @@ from __future__ import annotations
64
72
  import ctypes
65
73
  import mmap
66
74
  import os
75
+ import posixpath
67
76
  import queue
77
+ import re
68
78
  import shutil
69
79
  import threading
70
80
  import time
@@ -234,25 +244,34 @@ def detect_disks() -> List[DiskInfo]:
234
244
  跨平台:Windows 用 ctypes(GetDriveTypeW),Linux/WSL 用 psutil.disk_partitions。
235
245
  """
236
246
  disks: List[DiskInfo] = []
237
- if os.name != "nt": # Linux / WSL / macOS:psutil 跨平台路径
247
+ if os.name != "nt": # Linux / WSL / macOS:psutil 枚举 + /proc/mounts 安全判定
238
248
  import psutil as _ps
249
+ seen = set()
239
250
  for part in _ps.disk_partitions(all=False):
240
- # 【防炸机】WSL 会把宿主 NTFS 经 9P 挂成 /mnt/c、/mnt/d——9P(网络文件
241
- # 系统语义)在高负载下对 NTFS 做冷参数卸载会扰乱宿主 MFT(已实测炸机)。
242
- # 必须排除 9p/fuse/宿主 NTFS 挂载,绝不对它们卸载冷参数。
251
+ # 先排除 WSL 宿主 NTFS / 9P / 网络挂载(历史炸机点)
243
252
  if _is_wsl_host_mount(part):
244
253
  continue
254
+ mount = part.mountpoint
255
+ norm = os.path.normpath(mount)
256
+ if norm in seen:
257
+ continue
258
+ info = _mount_for_path(mount)
259
+ if info is None:
260
+ continue
261
+ mnt, fs, src, opts = info
262
+ ok, _reason = _fs_safety(fs, mnt, opts)
263
+ if not ok:
264
+ continue # 9P / ntfs / 网络 / 只读 / 伪文件系统 / 内存盘
245
265
  try:
246
- usg = _ps.disk_usage(part.mountpoint)
266
+ usg = _ps.disk_usage(mount)
247
267
  except Exception:
248
268
  continue
249
- mount = part.mountpoint
250
- # Linux 不区分 removable/remote(psutil 的 fstype 判 ssd/hdd 不准确,
251
- # 退化按 SSD 处理——Linux 上卸载保护磁盘寿命的策略以 SSD 为准)
252
- drive_type = "ssd" if _fs_ssd_like(part.fstype) else "hdd"
269
+ if usg.total <= 0 or usg.free <= 0:
270
+ continue
271
+ seen.add(norm)
253
272
  disks.append(DiskInfo(
254
273
  mount=mount,
255
- drive_type=drive_type,
274
+ drive_type=_linux_drive_type(src or part.device),
256
275
  total_bytes=usg.total,
257
276
  free_bytes=usg.free,
258
277
  ))
@@ -348,6 +367,185 @@ def _in_wsl() -> bool:
348
367
  return False
349
368
 
350
369
 
370
+ # ---------------------------------------------------------------------------
371
+ # Linux / WSL 文件系统安全(WSL 支持的核心)
372
+ # ---------------------------------------------------------------------------
373
+ # 9P / 网络 / 宿主盘:绝不允许做冷参数缓存(WSL 的 /mnt/c、/mnt/d 属于这一类,历史上
374
+ # 在高负载下扰乱过宿主 MFT)。pseudo/RAM 文件系统则没有持久化/卸载意义。
375
+ _UNSAFE_FS_PREFIXES = (
376
+ "9p", "9pfs", "drvfs", "fuseblk", "fuse.", "ntfs", "ntfs3", "cifs", "smb",
377
+ "nfs", "nfs4", "vfat", "exfat", "iso9660", "udf", "sshfs", "ceph", "glusterfs",
378
+ "afs", "lustre", "beegfs",
379
+ )
380
+ _PSEUDO_FS = frozenset((
381
+ "proc", "sysfs", "devtmpfs", "devpts", "cgroup", "cgroup2", "binfmt_misc",
382
+ "securityfs", "debugfs", "tracefs", "pstore", "configfs", "mqueue", "hugetlbfs",
383
+ "autofs", "efivarfs", "nsfs", "tracefs",
384
+ ))
385
+ _RAM_FS = frozenset(("tmpfs", "ramfs"))
386
+ _WSL_HOST_MOUNT_RE = re.compile(r"^/mnt/[A-Za-z](/|$)")
387
+
388
+
389
+ def _read_mounts(mounts_path: str = "/proc/mounts") -> List[Tuple[str, str, str, str]]:
390
+ """[(mountpoint, fstype, source, opts)],按 mountpoint 长度降序(最长前缀优先)。
391
+
392
+ Linux/WSL 上用 /proc/mounts 而不是只信 psutil:psutil 在 WSL 里可能看不到 9P 宿主盘,
393
+ 而我们恰恰需要识别并拒绝它们。参数化路径是为了单测能喂假 mount 表。
394
+ """
395
+ rows: List[Tuple[str, str, str, str]] = []
396
+ try:
397
+ with open(mounts_path, "r", encoding="utf-8", errors="replace") as fh:
398
+ for line in fh:
399
+ parts = line.split()
400
+ if len(parts) < 4:
401
+ continue
402
+ src, mnt, fs, opts = parts[0], parts[1], parts[2], parts[3]
403
+ mnt = mnt.replace("\\040", " ").replace("\\011", "\t")
404
+ rows.append((mnt, fs.lower(), src, opts))
405
+ except OSError:
406
+ return []
407
+ rows.sort(key=lambda r: len(r[0]), reverse=True)
408
+ return rows
409
+
410
+
411
+ def _norm_mount_path(path: str) -> str:
412
+ """把路径规范成用于 /proc/mounts 前缀比较的形式。
413
+
414
+ 在 Linux 上就是 abspath;在 Windows 上跑 Linux mount 表单测时,os.path.abspath("/mnt/x")
415
+ 会变成 "D:\\mnt\\x",所以纯 POSIX 路径改用 posixpath.normpath。
416
+ """
417
+ s = str(path)
418
+ if s.startswith("/") and os.name == "nt":
419
+ return posixpath.normpath(s)
420
+ return os.path.abspath(os.path.expanduser(s))
421
+
422
+
423
+ def _mount_for_path(path: str, mounts_path: str = "/proc/mounts"):
424
+ """路径所在挂载行 (mountpoint, fstype, source, opts);解析不到返回 None。"""
425
+ try:
426
+ ap = _norm_mount_path(path)
427
+ except Exception:
428
+ return None
429
+ for row in _read_mounts(mounts_path):
430
+ mnt = row[0]
431
+ if ap == mnt or ap.startswith(mnt.rstrip("/") + "/"):
432
+ return row
433
+ return None
434
+
435
+
436
+ def _fs_safety(fs: str, mount: str, opts: str = "", is_wsl: Optional[bool] = None):
437
+ """(ok, reason):该挂载能否承载冷参数缓存。"""
438
+ if is_wsl is None:
439
+ is_wsl = _in_wsl()
440
+ if fs.startswith(_UNSAFE_FS_PREFIXES):
441
+ return False, f"{fs} 是 9P/网络/宿主盘文件系统(挂载 {mount})"
442
+ if fs in _PSEUDO_FS:
443
+ return False, f"{fs} 是伪文件系统(挂载 {mount})"
444
+ if fs in _RAM_FS:
445
+ return False, f"{fs} 是内存盘,卸载到它没有意义(挂载 {mount})"
446
+ if "ro" in opts.split(","):
447
+ return False, f"挂载 {mount} 是只读的"
448
+ if is_wsl and (_WSL_HOST_MOUNT_RE.match(mount) or mount.startswith("/mnt/wsl")):
449
+ return False, f"{mount} 是 WSL 宿主/虚拟挂载,不能写冷参数"
450
+ return True, ""
451
+
452
+
453
+ def _cache_dir_verdict(path: str, mounts_path: str = "/proc/mounts"):
454
+ """(ok, reason):路径能否作为 disk_balancer 的缓存目录。
455
+
456
+ 比 _fs_safety 多两条:不能直接是 "/"(cleanup 会 rmtree 它),且不能落在 WSL 宿主盘。
457
+ """
458
+ try:
459
+ ap = _norm_mount_path(path)
460
+ except Exception:
461
+ return False, "路径无法解析"
462
+ if not ap.rstrip("/") or ap == os.sep:
463
+ return False, "缓存目录不能直接是根目录 /"
464
+ info = _mount_for_path(ap, mounts_path)
465
+ if info is None:
466
+ return False, "无法解析该路径的挂载信息(需要 Linux /proc/mounts)"
467
+ mnt, fs, _src, opts = info
468
+ ok, reason = _fs_safety(fs, mnt, opts)
469
+ if not ok:
470
+ return False, reason
471
+ if _in_wsl() and _WSL_HOST_MOUNT_RE.match(ap):
472
+ return False, f"{ap} 在 WSL 宿主盘 /mnt/[盘符] 下"
473
+ return True, f"{fs} @ {mnt}"
474
+
475
+
476
+ def _cache_name_for_mount(mount: str) -> str:
477
+ """把挂载点变成安全的目录名:"/" -> "root","/mnt/data" -> "mnt_data"。"""
478
+ name = str(mount).strip("/").replace("/", "_").replace(":", "").replace("\\", "_")
479
+ return name or "root"
480
+
481
+
482
+ def _default_cache_base() -> str:
483
+ """Linux 默认缓存根:$XDG_CACHE_HOME/bnb_flash_cache 或 ~/.cache/bnb_flash_cache。
484
+
485
+ 不用脚本所在目录做默认:WSL 里脚本常在 /mnt/d(9P),那是必须拒绝的位置。
486
+ """
487
+ xdg = os.environ.get("XDG_CACHE_HOME")
488
+ if not xdg:
489
+ xdg = os.path.join(os.path.expanduser("~"), ".cache")
490
+ return os.path.join(xdg, "bnb_flash_cache")
491
+
492
+
493
+ def _linux_drive_type(source: str) -> str:
494
+ """按 /sys/block/<dev>/queue/rotational 判断 SSD/HDD/NVMe(Linux 真实介质类型)。"""
495
+ name = os.path.basename(str(source or "").strip())
496
+ if not name:
497
+ return "ssd"
498
+ if name.startswith("nvme"):
499
+ return "nvme"
500
+ try:
501
+ with open(f"/sys/class/block/{name}/queue/rotational", "r", encoding="utf-8") as fh:
502
+ rotational = fh.read().strip() == "1"
503
+ except OSError:
504
+ return "ssd"
505
+ if rotational and _in_wsl():
506
+ # WSL 的虚拟盘(Hyper-V VHDX)永远报告 rotational=1,但宿主一般是 SSD;WSL 里
507
+ # 也只有一个可用路径,分错层没有意义,按 SSD 标更符合实际。
508
+ return "ssd"
509
+ return "hdd" if rotational else "ssd"
510
+
511
+
512
+ def _unix_cache_candidates(mount: str, cfg_paths: List[str]) -> List[str]:
513
+ """按优先级给出某个挂载点上的缓存目录候选(都会再过 _cache_dir_verdict 检查)。"""
514
+ cands: List[str] = []
515
+ for cp in cfg_paths or []:
516
+ info = _mount_for_path(cp)
517
+ if info is not None and os.path.normpath(info[0]) == os.path.normpath(mount):
518
+ cands.append(os.path.join(os.path.abspath(os.path.expanduser(cp)),
519
+ ".bnb_flash_cache"))
520
+ if os.path.normpath(mount) != os.sep:
521
+ cands.append(os.path.join(mount, ".bnb_flash_cache"))
522
+ cands.append(os.path.join(_default_cache_base(), _cache_name_for_mount(mount)))
523
+ return cands
524
+
525
+
526
+ def _writable_dir(path: str) -> bool:
527
+ try:
528
+ os.makedirs(path, exist_ok=True)
529
+ probe = os.path.join(path, f".wtest_{os.getpid()}")
530
+ with open(probe, "wb") as fh:
531
+ fh.write(b"x")
532
+ os.remove(probe)
533
+ return True
534
+ except OSError:
535
+ return False
536
+
537
+
538
+ def _pick_unix_cache_dir(mount: str, cfg_paths: List[str]) -> Optional[str]:
539
+ """在某个挂载点上选一个安全且可写的缓存目录;没有就返回 None。"""
540
+ for cand in _unix_cache_candidates(mount, cfg_paths):
541
+ ok, reason = _cache_dir_verdict(cand)
542
+ if not ok:
543
+ continue
544
+ if _writable_dir(cand):
545
+ return cand
546
+ return None
547
+
548
+
351
549
  def _fs_ssd_like(fstype: str) -> bool:
352
550
  """粗略判断文件系统/挂载点是否更像 SSD(Linux 无设备级 SSD 检测,用 psutil
353
551
  disk_io_counters 无法区分 ssd/hdd,这里用 fstype 兜底:NVMe/常见 SSD 挂载)。
@@ -408,6 +606,10 @@ def get_disk_activity(disk: DiskInfo) -> float:
408
606
  """获取磁盘当前活动率(估算值,0.0~1.0)。
409
607
 
410
608
  通过两次采样间的 I/O 增量估算。
609
+
610
+ Linux 上还有两个可选环境变量:``BNB_FLASH_FADV_DONTNEED=1`` 让写完后尽早释放页缓存
611
+ (内存极紧时有用、下次读会慢);``BNB_FLASH_FSYNC=1`` 等价于
612
+ ``DiskBalancerConfig(fsync=True)`` 的默认值(每个缓存文件落盘后 fsync)。
411
613
  """
412
614
  r, w, now = _get_disk_io(disk)
413
615
  if disk._io_counters is None:
@@ -437,9 +639,13 @@ class DiskBalancerConfig:
437
639
 
438
640
  Attributes:
439
641
  mode: "auto" 自动检测磁盘并分配;"manual" 手动指定。
440
- sizes: 手动模式下每盘缓存大小,如 ``{"C:": 2048, "D:": 4096}``(MB)。
441
- paths: 指定缓存路径列表,如 ``["D:\\cache", "E:\\cache"]``。
442
- 不指定则默认在脚本所在目录下创建 ``.flash_cache``。
642
+ sizes: 手动模式下每盘缓存大小(MB)。Windows 用盘符 ``{"C:": 2048}``;
643
+ Linux/WSL 用挂载点 ``{"/": 2048, "/mnt/data": 4096}``。
644
+ paths: 指定缓存路径列表,如 Windows ``["D:\\cache", "E:\\cache"]``,
645
+ Linux/WSL ``["/home/user/fastcache"]``(会落在其下 `.bnb_flash_cache/`)。
646
+ 不指定则 Windows 默认在脚本所在目录 `.flash_cache/`,Linux/WSL 默认在
647
+ ``$XDG_CACHE_HOME/bnb_flash_cache``(或 ``~/.cache/bnb_flash_cache``)。
648
+ Linux/WSL 上 9P/网络/只读/内存盘路径会被拒绝并报错。
443
649
  speed_first: True=速度优先(不管硬盘寿命),False=保护硬盘优先。
444
650
  keep_cache: True=训练结束后保留缓存文件,False=默认删除。
445
651
  memory_threshold: 内存使用率阈值(0.0~1.0),超过此值触发冷参数卸载。
@@ -467,6 +673,12 @@ class DiskBalancerConfig:
467
673
  min_free_ratio: float = 0.05
468
674
  offload_prefix: str = "" # 只卸载匹配前缀的冷参数(空=全部),如 "_cold_"
469
675
  offload_per_step: int = 0 # 每次 update_step 最多迁移几个(0=不限)
676
+ # 零拷贝写盘队列里每个待写参数都会继续占用源张量内存直到写完。不设上限时,
677
+ # 训练线程可以一次排进几十个大参数(几百 MB 内存没真正释放)。下面两个旋钮把
678
+ # "在飞字节"限制住,写盘线程消费后自动放行。
679
+ max_inflight_bytes: int = 0 # 0=自动(max(256MB, min(1GB, 5% 物理内存)))
680
+ write_queue_depth: int = 0 # 0=不限(内存由 max_inflight_bytes 兜底)
681
+ fsync: bool = False # True=每个缓存文件落盘后 fsync(更安全、更慢)
470
682
 
471
683
  def __post_init__(self):
472
684
  """校验关键配置,避免非法值导致静默禁用 / 负缓存 / None 崩。"""
@@ -489,6 +701,12 @@ class DiskBalancerConfig:
489
701
  if self.throttle_threshold is None or not (0.0 <= self.throttle_threshold <= 1.0):
490
702
  raise ValueError(
491
703
  f"throttle_threshold 必须在 [0,1] 内,得到 {self.throttle_threshold}")
704
+ if self.max_inflight_bytes is None or self.max_inflight_bytes < 0:
705
+ raise ValueError(
706
+ f"max_inflight_bytes 必须是非负整数(0=自动),得到 {self.max_inflight_bytes}")
707
+ if self.write_queue_depth is None or self.write_queue_depth < 0:
708
+ raise ValueError(
709
+ f"write_queue_depth 必须是非负整数(0=不限),得到 {self.write_queue_depth}")
492
710
 
493
711
 
494
712
  # ---------------------------------------------------------------------------
@@ -511,6 +729,9 @@ class DiskLoadBalancer:
511
729
  - 检测所有磁盘,按 SSD→HDD 排序路径列表
512
730
  - 按容量自动分配缓存配额(保留 min_free_ratio 安全空间)
513
731
  - 单一磁盘时:优先不拖慢训练速度,其次是保护硬盘
732
+ - Linux/WSL:只选本机磁盘文件系统;没有任何安全位置时 `start()` 直接报错并说明原因
733
+ - 零拷贝写盘队列有在飞字节上限(`max_inflight_bytes`),超过时本次卸载放弃、
734
+ 参数留在内存,避免"写盘没完成、内存也没真正释放"的假卸载
514
735
 
515
736
  用法::
516
737
 
@@ -529,19 +750,24 @@ class DiskLoadBalancer:
529
750
 
530
751
  def __init__(self, config: Optional[DiskBalancerConfig] = None):
531
752
  self._cfg = config or DiskBalancerConfig()
532
- # 【WSL 环境整体禁用】在 WSL 里,宿主 NTFS 经 9P/drvfs 挂在 /mnt/c、/mnt/d 等。
533
- # 9P(网络文件系统语义)在高负载下对 NTFS 卸载冷参数会扰乱宿主 MFT(已实测炸机)。
534
- # 所以:当处于 WSL / 9P 宿主环境时,均衡负载整体跳过、忽略用户参数,不卸载任何冷参数。
535
- self._9p_skip = _in_wsl()
753
+ # WSL 不再整体禁用:WSL 发行版自己的 ext4(/dev/sdX 挂 /)是安全的本机磁盘;
754
+ # 只拒绝 9P 宿主盘(/mnt/c、/mnt/d 等)。_disabled_reason 记录最终为什么没跑。
755
+ self._wsl = _in_wsl()
756
+ self._disabled_reason = ""
536
757
  self._disks: List[DiskInfo] = []
537
758
  self._paths: List[str] = [] # 排序后的缓存路径(SSD→HDD)
538
- self._write_queue: queue.Queue = queue.Queue()
759
+ self._write_queue: queue.Queue = queue.Queue(
760
+ maxsize=self._cfg.write_queue_depth or 0)
539
761
  self._writer_thread: Optional[threading.Thread] = None
540
762
  self._model: Optional[torch.nn.Module] = None
541
763
  self._cold_index: Dict[str, Tuple[str, int, torch.dtype]] = {} # name -> (path, numel, dtype)
542
764
  self._migrated_count: int = 0
543
765
  self._started = False
544
766
  self._lock = threading.Lock()
767
+ self._inflight_bytes = 0 # 已入队、未写完的源张量字节数
768
+ self._inflight_cv = threading.Condition(self._lock)
769
+ self._max_inflight_bytes = 0 # start() 里算
770
+ self._write_bytes_total = 0 # 累计入队字节数(统计)
545
771
  self._script_dir: str = ""
546
772
  self._ssd_paths: List[str] = []
547
773
  self._hdd_paths: List[str] = []
@@ -566,21 +792,28 @@ class DiskLoadBalancer:
566
792
  def start(self, script_dir: Optional[str] = None) -> "DiskLoadBalancer":
567
793
  """初始化磁盘、创建缓存目录、启动异步写盘线程。
568
794
 
795
+ 在 WSL / Linux 上只会选择本机磁盘文件系统(ext4/xfs/btrfs/...);9P 宿主盘
796
+ (/mnt/c、/mnt/d)、网络盘、只读挂载和内存盘会被拒绝。没有任何安全位置时
797
+ 本次实例自动禁用(不写任何文件),并在日志里说明原因。
798
+
569
799
  Args:
570
- script_dir: 训练脚本所在目录。缓存路径默认在此目录下。
800
+ script_dir: 训练脚本所在目录(Windows 默认缓存位置;Linux 用 ~/.cache)。
571
801
  """
572
- if getattr(self, "_9p_skip", False):
573
- print("[disk_balancer] 检测到 WSL 9P/宿主 NTFS 挂载,均衡负载已整体禁用——"
574
- "为避免 9P 写宿主 NTFS 扰乱 MFT(会炸机),本次不卸载任何冷参数"
575
- "(用户 --flash 参数已忽略)。建议在纯 Linux 分区使用才对磁盘做均衡。")
576
- return self
577
-
578
802
  if self._started:
579
803
  return self
580
804
 
581
805
  self._script_dir = script_dir or os.path.dirname(os.path.abspath(
582
806
  __import__("sys").argv[0] if __import__("sys").argv else __file__))
583
807
 
808
+ # 用户显式 --flash_paths 必须安全:Linux/WSL 上如果配到 9P 宿主盘/网络盘/根目录,
809
+ # 直接报错而不是悄悄忽略(否则用户以为缓存落在指定位置,实际没有)。
810
+ if os.name != "nt" and self._cfg.paths:
811
+ for cp in self._cfg.paths:
812
+ ok, reason = _cache_dir_verdict(cp)
813
+ if not ok:
814
+ raise ValueError(
815
+ f"disk_balancer: --flash_paths 中的 {cp!r} 不安全: {reason}。"
816
+ f"请换成 ext4/xfs/btrfs 等本机磁盘上的目录(例如 $HOME 下)")
584
817
  if self._cfg.mode == "auto":
585
818
  self._start_auto()
586
819
  elif self._cfg.mode == "manual":
@@ -588,6 +821,27 @@ class DiskLoadBalancer:
588
821
  else:
589
822
  raise ValueError(f"Unknown mode: {self._cfg.mode}")
590
823
 
824
+ # 在任何写操作之前,把每个缓存目录再验一遍(防配置绕过;Linux 上同时保证
825
+ # 绝不会是 "/" 或 9P 宿主盘)。Windows 没有 /proc/mounts,跳过该检查。
826
+ if os.name != "nt":
827
+ for p in self._paths:
828
+ ok, reason = _cache_dir_verdict(p)
829
+ if not ok:
830
+ raise RuntimeError(
831
+ f"disk_balancer: 拒绝不安全缓存目录 {p}: {reason}。"
832
+ f"请改用本机 Linux 文件系统上的路径(例如 $HOME)。")
833
+
834
+ # 在飞字节上限:零拷贝队列持有源张量内存,必须给训练线程一个背压。
835
+ if self._cfg.max_inflight_bytes:
836
+ self._max_inflight_bytes = int(self._cfg.max_inflight_bytes)
837
+ else:
838
+ try:
839
+ import psutil as _ps
840
+ total = int(_ps.virtual_memory().total)
841
+ self._max_inflight_bytes = max(256 << 20, min(1 << 30, total // 20))
842
+ except Exception:
843
+ self._max_inflight_bytes = 512 << 20
844
+
591
845
  # Only directories we actually create here are ever removed by cleanup().
592
846
  self._owned_cache_dirs = []
593
847
  for p in self._paths:
@@ -617,7 +871,31 @@ class DiskLoadBalancer:
617
871
  def _start_auto(self):
618
872
  all_disks = detect_disks()
619
873
  if not all_disks:
620
- raise RuntimeError("disk_balancer: 未检测到任何可用磁盘")
874
+ hint = ("(WSL:只检测到 9P 宿主盘?把缓存目录放到 WSL 自己的 ext4 上,"
875
+ "或确保 $HOME 可写)" if self._wsl else "")
876
+ raise RuntimeError(f"disk_balancer: 未检测到任何可用磁盘{hint}")
877
+
878
+ if os.name != "nt":
879
+ kept: List[DiskInfo] = []
880
+ for d in all_disks:
881
+ usable = int(d.free_bytes * (1.0 - self._cfg.min_free_ratio))
882
+ d.max_cache_bytes = min(usable, 4 * (1 << 30))
883
+ cache_dir = _pick_unix_cache_dir(d.mount, self._cfg.paths)
884
+ if cache_dir is None:
885
+ continue
886
+ d.cache_dir = cache_dir
887
+ if d.drive_type in ("ssd", "nvme"):
888
+ self._ssd_paths.append(cache_dir)
889
+ else:
890
+ self._hdd_paths.append(cache_dir)
891
+ kept.append(d)
892
+ if not kept:
893
+ raise RuntimeError(
894
+ "disk_balancer: 没有安全的 Linux 缓存目录可用"
895
+ "(9P/网络/只读/内存盘都不能用;请把缓存放到 ext4/xfs/btrfs 等本机磁盘)")
896
+ self._disks = kept
897
+ self._paths = self._ssd_paths + self._hdd_paths
898
+ return
621
899
 
622
900
  # 按 SSD→HDD 排序路径
623
901
  for d in all_disks:
@@ -650,6 +928,33 @@ class DiskLoadBalancer:
650
928
  all_disks = detect_disks()
651
929
  disk_map = {d.mount: d for d in all_disks}
652
930
 
931
+ if os.name != "nt":
932
+ # Linux:sizes 的 key 是挂载点路径("/"、"/mnt/data"),不是盘符。
933
+ norm_map = {os.path.normpath(k): v for k, v in disk_map.items()}
934
+ for mount_key, size_mb in self._cfg.sizes.items():
935
+ m = os.path.normpath(str(mount_key))
936
+ if m not in norm_map:
937
+ raise ValueError(
938
+ f"disk_balancer: 挂载点 {mount_key!r} 不在可用磁盘列表中: "
939
+ f"{sorted(norm_map)}")
940
+ d = norm_map[m]
941
+ usable = int(d.free_bytes * (1.0 - self._cfg.min_free_ratio))
942
+ d.max_cache_bytes = min(int(size_mb) << 20, usable)
943
+ cache_dir = _pick_unix_cache_dir(d.mount, self._cfg.paths)
944
+ if cache_dir is None:
945
+ raise RuntimeError(
946
+ f"disk_balancer: 挂载点 {d.mount} 上没有安全可写的缓存目录")
947
+ d.cache_dir = cache_dir
948
+ if d.drive_type in ("ssd", "nvme"):
949
+ self._ssd_paths.append(cache_dir)
950
+ else:
951
+ self._hdd_paths.append(cache_dir)
952
+ self._disks.append(d)
953
+ if not self._disks:
954
+ raise RuntimeError("disk_balancer: manual 模式下没有有效的磁盘配置")
955
+ self._paths = self._ssd_paths + self._hdd_paths
956
+ return
957
+
653
958
  for mount, size_mb in self._cfg.sizes.items():
654
959
  m = mount.upper()
655
960
  if m not in disk_map:
@@ -693,8 +998,13 @@ class DiskLoadBalancer:
693
998
  m = "SSD" if d.drive_type in ("ssd", "nvme") else "HDD"
694
999
  lines.append(f" [{d.mount}] {m} 缓存 {d.max_cache_gb:.1f}GB @ {d.cache_dir}")
695
1000
  lines.append(f" 路径优先级: {self._paths}")
1001
+ if self._max_inflight_bytes:
1002
+ lines.append(f" 在飞写盘上限: {self._max_inflight_bytes / (1 << 20):.0f} MB"
1003
+ f"(超过则本次卸载放弃,参数留在内存)")
696
1004
  if self._cfg.speed_first:
697
1005
  lines.append(" ⚠ 速度优先模式:不保护硬盘,不节流")
1006
+ if self._wsl:
1007
+ lines.append(" WSL:仅使用本机 ext4 等磁盘文件系统,9P 宿主盘 /mnt/[盘符] 绝不写入")
698
1008
  print("\n".join(lines), flush=True)
699
1009
 
700
1010
  # ------------------------------------------------------------------
@@ -710,33 +1020,50 @@ class DiskLoadBalancer:
710
1020
  """
711
1021
  while True:
712
1022
  try:
713
- key, payload, path = self._write_queue.get()
714
- try:
715
- os.makedirs(os.path.dirname(path), exist_ok=True)
716
- with open(path, "wb") as f:
717
- f.write(payload)
718
- except Exception as e:
719
- # 写盘失败:不能静默丢数据 —— 从索引摘除该 key(内存已释放,必须显式
720
- # 摘除并报警,否则调用方拿回一个"已登记但文件不存在/未被占用"的参数,
721
- # 冻结层前向会悄悄变垃圾)。
722
- with self._lock:
723
- entry = self._cold_index.pop(key, None)
724
- if entry:
725
- cache_dir = os.path.dirname(entry[0])
726
- try:
727
- nbytes = int(entry[1]) * torch.empty(0, dtype=entry[2]).element_size()
728
- except (TypeError, ValueError):
729
- nbytes = 0
730
- self._cache_bytes[cache_dir] = max(
731
- 0, self._cache_bytes.get(cache_dir, 0) - nbytes)
732
- print(f"[disk_balancer] 写盘失败 {key}: {e} —— 已从索引摘除,该参数将被丢弃(无法读回)", flush=True)
733
- finally:
734
- if isinstance(payload, memoryview):
735
- payload.release()
736
- self._write_queue.task_done()
1023
+ key, payload, path, nbytes = self._write_queue.get()
737
1024
  except Exception:
738
- # queue 关闭或线程退出
739
- break
1025
+ break # queue 关闭或线程退出
1026
+ try:
1027
+ cache_dir = os.path.dirname(path)
1028
+ self._wait_disk_idle(cache_dir)
1029
+ os.makedirs(cache_dir, exist_ok=True)
1030
+ with open(path, "wb") as f:
1031
+ f.write(payload)
1032
+ if self._cfg.fsync:
1033
+ f.flush()
1034
+ os.fsync(f.fileno())
1035
+ # 内存紧张时可让内核尽快丢掉刚写的页缓存(下次读会从盘上来,慢一点)。
1036
+ if os.name != "nt" and os.environ.get("BNB_FLASH_FADV_DONTNEED"):
1037
+ try:
1038
+ fd = os.open(path, os.O_RDONLY)
1039
+ try:
1040
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
1041
+ finally:
1042
+ os.close(fd)
1043
+ except OSError:
1044
+ pass
1045
+ except Exception as e:
1046
+ # 写盘失败:不能静默丢数据 —— 从索引摘除该 key(内存已释放,必须显式
1047
+ # 摘除并报警,否则调用方拿回一个"已登记但文件不存在/未被占用"的参数,
1048
+ # 冻结层前向会悄悄变垃圾)。
1049
+ with self._lock:
1050
+ entry = self._cold_index.pop(key, None)
1051
+ if entry:
1052
+ cache_dir = os.path.dirname(entry[0])
1053
+ try:
1054
+ nbytes_entry = int(entry[1]) * torch.empty(0, dtype=entry[2]).element_size()
1055
+ except (TypeError, ValueError):
1056
+ nbytes_entry = 0
1057
+ self._cache_bytes[cache_dir] = max(
1058
+ 0, self._cache_bytes.get(cache_dir, 0) - nbytes_entry)
1059
+ print(f"[disk_balancer] 写盘失败 {key}: {e} —— 已从索引摘除,该参数将被丢弃(无法读回)", flush=True)
1060
+ finally:
1061
+ if isinstance(payload, memoryview):
1062
+ payload.release()
1063
+ with self._inflight_cv:
1064
+ self._inflight_bytes = max(0, self._inflight_bytes - nbytes)
1065
+ self._inflight_cv.notify_all()
1066
+ self._write_queue.task_done()
740
1067
 
741
1068
  # ------------------------------------------------------------------
742
1069
  # 冷参数检测与卸载
@@ -774,6 +1101,19 @@ class DiskLoadBalancer:
774
1101
  # 兜底:第一个路径
775
1102
  return self._paths[0] if self._paths else ""
776
1103
 
1104
+ def _wait_disk_idle(self, cache_dir: str, timeout: float = 2.0) -> None:
1105
+ """写盘前等磁盘活动率降下来,避免训练线程和卸载线程一起把盘打满。
1106
+
1107
+ speed_first=True 时完全跳过(该模式的卖点就是不管硬盘)。
1108
+ """
1109
+ if self._cfg.speed_first or not self._disks:
1110
+ return
1111
+ deadline = time.time() + timeout
1112
+ while time.time() < deadline:
1113
+ if self._check_disk_activity(cache_dir):
1114
+ return
1115
+ time.sleep(0.02)
1116
+
777
1117
  def _check_disk_activity(self, cache_dir: str) -> bool:
778
1118
  """检查磁盘是否过载。"""
779
1119
  if self._cfg.speed_first:
@@ -815,7 +1155,7 @@ class DiskLoadBalancer:
815
1155
  def maybe_offload_latent(self, key: str, latent: torch.Tensor,
816
1156
  t0: Optional[int] = None, t1: Optional[int] = None) -> Optional[torch.Tensor]:
817
1157
  """内存接近阈值时自动把 latent 分块卸载;返回 None 表示已卸载,调用方应释放原张量。"""
818
- if getattr(self, "_9p_skip", False) or self._latent_store is None or not self._started:
1158
+ if not self._started or self._disabled_reason or self._latent_store is None:
819
1159
  return latent
820
1160
  if not self._mem_pressure():
821
1161
  return latent
@@ -853,9 +1193,7 @@ class DiskLoadBalancer:
853
1193
  Returns:
854
1194
  本次迁移的参数数量。
855
1195
  """
856
- # 【9P 环境整体禁用】WSL 的 9P/宿主 NTFS 挂载下,卸载冷参数会扰宿主 MFT(炸机)。
857
- # 直接跳过、忽略用户参数(不迁移任何冷参数),宁可不做也要安全。
858
- if getattr(self, "_9p_skip", False):
1196
+ if not self._started or self._disabled_reason:
859
1197
  return 0
860
1198
  if self._model is None:
861
1199
  return 0
@@ -928,7 +1266,7 @@ class DiskLoadBalancer:
928
1266
  注意:成功卸载后 ``tensor`` 的 storage 会被置空(``data = empty(0)``)。
929
1267
  调用方必须自己保存原始 shape,读回时用 ``get_cold_shape(key, shape)``。
930
1268
  """
931
- if not self._started:
1269
+ if not self._started or self._disabled_reason:
932
1270
  return ""
933
1271
 
934
1272
  self._ensure_dirs()
@@ -936,6 +1274,22 @@ class DiskLoadBalancer:
936
1274
  if not cache_dir:
937
1275
  return ""
938
1276
  nbytes = int(tensor.numel()) * tensor.element_size()
1277
+
1278
+ # min_free_ratio 要真正生效:写之前看该挂载的实时剩余空间(配置里的 DiskInfo
1279
+ # 是 start() 时刻的快照,训练几小时后可能已经不准)。
1280
+ try:
1281
+ usg = shutil.disk_usage(cache_dir)
1282
+ if usg.total > 0 and usg.free < usg.total * self._cfg.min_free_ratio:
1283
+ print(
1284
+ f"[disk_balancer] 磁盘剩余空间不足({cache_dir}: "
1285
+ f"{usg.free / (1 << 30):.1f}GB free < "
1286
+ f"{self._cfg.min_free_ratio * 100:.0f}%),跳过卸载 {key}",
1287
+ flush=True,
1288
+ )
1289
+ return ""
1290
+ except OSError:
1291
+ pass
1292
+
939
1293
  quota = 0
940
1294
  for d in self._disks:
941
1295
  if d.cache_dir == cache_dir:
@@ -949,18 +1303,55 @@ class DiskLoadBalancer:
949
1303
  flush=True,
950
1304
  )
951
1305
  return ""
952
- path = os.path.join(cache_dir, f"{key}.bin")
953
1306
 
954
- # 记录元数据
1307
+ # 背压:零拷贝队列持有源张量内存,写盘线程消费前内存并未真正释放。
1308
+ # 超过在飞上限时短暂等待;等不到就放弃这次卸载(参数留在内存,行为安全)。
1309
+ deadline = time.time() + 5.0
1310
+ with self._inflight_cv:
1311
+ while (self._max_inflight_bytes
1312
+ and self._inflight_bytes + nbytes > self._max_inflight_bytes):
1313
+ remain = deadline - time.time()
1314
+ if remain <= 0:
1315
+ break
1316
+ self._inflight_cv.wait(min(remain, 0.1))
1317
+ if (self._max_inflight_bytes
1318
+ and self._inflight_bytes + nbytes > self._max_inflight_bytes):
1319
+ print(
1320
+ f"[disk_balancer] 在飞写盘队列已满({self._inflight_bytes / (1 << 20):.0f}MB"
1321
+ f" / {self._max_inflight_bytes / (1 << 20):.0f}MB),"
1322
+ f"跳过卸载 {key},参数保留在内存",
1323
+ flush=True,
1324
+ )
1325
+ return ""
1326
+ self._inflight_bytes += nbytes
1327
+
1328
+ path = os.path.join(cache_dir, f"{key}.bin")
955
1329
  dtype = tensor.dtype
956
1330
  numel = tensor.numel()
957
-
958
1331
  with self._lock:
959
1332
  self._cold_index[key] = (path, numel, dtype)
960
1333
  self._cache_bytes[cache_dir] = self._cache_bytes.get(cache_dir, 0) + nbytes
961
1334
 
962
1335
  # 异步写盘(零拷贝:队列里放的是源 storage 的视图,不复制字节)
963
- self._write_queue.put((key, self._payload_memoryview(tensor), path))
1336
+ payload = None
1337
+ try:
1338
+ payload = self._payload_memoryview(tensor)
1339
+ self._write_queue.put((key, payload, path, nbytes), timeout=5)
1340
+ except Exception as e:
1341
+ with self._lock:
1342
+ self._cold_index.pop(key, None)
1343
+ self._cache_bytes[cache_dir] = max(
1344
+ 0, self._cache_bytes.get(cache_dir, 0) - nbytes)
1345
+ with self._inflight_cv:
1346
+ self._inflight_bytes = max(0, self._inflight_bytes - nbytes)
1347
+ self._inflight_cv.notify_all()
1348
+ if isinstance(payload, memoryview):
1349
+ payload.release()
1350
+ print(f"[disk_balancer] 入队失败 {key}: {e};参数保留在内存", flush=True)
1351
+ return ""
1352
+
1353
+ with self._inflight_cv:
1354
+ self._write_bytes_total += nbytes
964
1355
 
965
1356
  # 释放内存
966
1357
  tensor.data = torch.empty(0, device=tensor.device, dtype=tensor.dtype)
@@ -994,6 +1385,17 @@ class DiskLoadBalancer:
994
1385
  if not os.path.exists(path):
995
1386
  return None
996
1387
 
1388
+ if os.name != "nt":
1389
+ # 告诉内核这文件马上要顺序读,尽量提前拉起 I/O。
1390
+ try:
1391
+ fd = os.open(path, os.O_RDONLY)
1392
+ try:
1393
+ os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_WILLNEED)
1394
+ finally:
1395
+ os.close(fd)
1396
+ except OSError:
1397
+ pass
1398
+
997
1399
  try:
998
1400
  with open(path, "rb") as f:
999
1401
  mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
@@ -1084,6 +1486,8 @@ class DiskLoadBalancer:
1084
1486
  如果 keep_cache=True,只清空索引,保留磁盘文件。
1085
1487
  如果 keep_cache=False(默认),删除所有磁盘缓存文件。
1086
1488
  """
1489
+ if not self._started:
1490
+ return
1087
1491
  self.wait_writes()
1088
1492
 
1089
1493
  if self._latent_store is not None:
@@ -1117,6 +1521,12 @@ class DiskLoadBalancer:
1117
1521
  "memory_percent": mem.percent,
1118
1522
  "memory_available_mb": mem.available / (1 << 20),
1119
1523
  "queue_size": self._write_queue.qsize(),
1524
+ "inflight_mb": self._inflight_bytes / (1 << 20),
1525
+ "max_inflight_mb": self._max_inflight_bytes / (1 << 20),
1526
+ "write_total_mb": self._write_bytes_total / (1 << 20),
1527
+ "fsync": self._cfg.fsync,
1528
+ "wsl": self._wsl,
1529
+ "disabled_reason": self._disabled_reason,
1120
1530
  "latent_chunks": len(self._latent_keys),
1121
1531
  "latent_offloaded": self._latent_offloaded,
1122
1532
  "disks": [
@@ -1156,11 +1566,12 @@ def add_flash_args(parser) -> None:
1156
1566
  )
1157
1567
  parser.add_argument(
1158
1568
  "--flash_sizes", type=str, default=None,
1159
- help="手动模式每盘缓存大小(MB),格式: C:2048,D:4096,E:8192",
1569
+ help="手动模式每盘缓存大小(MB)。Windows: C:2048,D:4096;Linux/WSL: /:2048,/mnt/data:4096",
1160
1570
  )
1161
1571
  parser.add_argument(
1162
1572
  "--flash_paths", type=str, default=None,
1163
- help="缓存路径,逗号分隔: D:\\cache,E:\\cache",
1573
+ help="缓存路径,逗号分隔。Windows: D:\\cache,E:\\cache;"
1574
+ "Linux/WSL: /home/me/fastcache(9P/网络/只读/内存盘会被拒绝)",
1164
1575
  )
1165
1576
  parser.add_argument(
1166
1577
  "--flash_speed", action="store_true", default=False,
@@ -27,6 +27,11 @@ from contextlib import nullcontext
27
27
  from typing import Any, Optional
28
28
 
29
29
  __all__ = [
30
+ "plan_concurrency",
31
+ "plan_concurrency_for_training",
32
+ "estimate_training_memory_gb",
33
+ "spawn_training_jobs",
34
+ "tune_threads",
30
35
  "apply_env",
31
36
  "init",
32
37
  "report",
@@ -169,6 +174,9 @@ def init(
169
174
  的 im2col/分块吃满);attention 小块反而 6 线程好,但整体 conv 主导;
170
175
  - ``None``:回退物理核数。
171
176
 
177
+ 注意:这些是静态建议(GEMM 密集假设)。混合注意力 / 短序列用 ``tune_threads()``
178
+ 在真实 step 上实测更准——6 核机器上实测 4 线程可能比 6 线程快 7~10%。
179
+
172
180
  返回调优报告文本(同 report() 的字符串,不是 dict)。
173
181
  """
174
182
  import torch
@@ -453,6 +461,324 @@ def suspend_mem_monitor(ev: Optional[threading.Event] = None) -> None:
453
461
  (ev or _MON_STOP).set()
454
462
 
455
463
 
464
+ # ---------------------------------------------------------------------------
465
+ # 并发规划:多进程 x 单线程 通常优于 单进程 x 多线程(2026-10-19 实测)
466
+ # ---------------------------------------------------------------------------
467
+ def plan_concurrency(
468
+ n_jobs: Optional[int] = None,
469
+ per_job_ram_gb: Optional[float] = None,
470
+ cores: Optional[int] = None,
471
+ available_ram_gb: Optional[float] = None,
472
+ ) -> dict:
473
+ """给「同时跑几个训练 job、每个开几个线程」一个可落地的建议。
474
+
475
+ 背景(R5-4500U,6C/6T,合成 3-block 训练 + AdamW8bit 实测)::
476
+
477
+ jobs x threads/job aggregate tok/s 相对最佳
478
+ 1 x 6 3843 62.8%
479
+ 2 x 3 4672 76.3%
480
+ 3 x 2 4973 81.2%
481
+ 6 x 1 5619 91.8%
482
+ 8 x 1(超订) 6123 100.0%
483
+
484
+ 结论:**多进程 x 单线程** 比 **单进程吃满多线程** 的聚合吞吐高约 60%——
485
+ OpenMP 线程池的 barrier/自旋等待在进程间是纯浪费。推荐打包 = 每个物理核一个
486
+ job、每 job 1 线程(6C 机器就是 6 个单线程 job;超订到 8 个还能再涨一点,但收益变平,
487
+ 且每个 job 都要一份内存)。RAM 是第二约束:每 job 的模型 + 优化器状态各自独立。
488
+
489
+ Args:
490
+ n_jobs: 想跑几个 job;None = 按物理核数推荐(每核 1 个)。
491
+ per_job_ram_gb: 单 job 预计占用的 RAM(GB)。None = 用当前进程 RSS 估算,
492
+ 所以**请在加载完一个模型后调用**(或显式传入训练脚本的常驻内存)。
493
+ cores: 物理核数;None = ``physical_cores()``。
494
+ available_ram_gb: 系统可用内存;None = psutil 读取。
495
+
496
+ Returns:
497
+ dict: ``n_jobs`` / ``threads_per_job`` / ``cores`` / ``per_job_ram_gb`` /
498
+ ``max_by_ram`` / ``env``(给子进程用的 OMP/MKL 环境变量) / ``advice``。
499
+
500
+ 用法::
501
+
502
+ import torch_cpu_kit as tck
503
+ plan = tck.plan_concurrency(per_job_ram_gb=1.5)
504
+ for i in range(plan["n_jobs"]):
505
+ subprocess.Popen([sys.executable, "train.py"], env={**os.environ, **plan["env"]})
506
+ """
507
+ cores = max(1, int(cores or physical_cores()))
508
+ if available_ram_gb is None:
509
+ try:
510
+ import psutil
511
+ available_ram_gb = psutil.virtual_memory().available / 1e9
512
+ except Exception:
513
+ available_ram_gb = 8.0
514
+ if per_job_ram_gb is None:
515
+ try:
516
+ import psutil
517
+ per_job_ram_gb = max(0.3, psutil.Process().memory_info().rss / 1e9)
518
+ except Exception:
519
+ per_job_ram_gb = 1.0
520
+
521
+ # 给系统/页缓存留 10%
522
+ max_by_ram = max(1, int((available_ram_gb * 0.9) // max(0.1, per_job_ram_gb)))
523
+ if n_jobs is None:
524
+ n_jobs = min(cores, max_by_ram)
525
+ else:
526
+ n_jobs = max(1, int(n_jobs))
527
+ if n_jobs > max_by_ram:
528
+ n_jobs = max_by_ram
529
+
530
+ if n_jobs <= 1:
531
+ threads_per_job = cores
532
+ else:
533
+ # 按核数均分;n_jobs >= cores 时每 job 1 线程(实测最优点)
534
+ threads_per_job = max(1, cores // n_jobs)
535
+
536
+ env = {
537
+ "OMP_NUM_THREADS": str(threads_per_job),
538
+ "MKL_NUM_THREADS": str(threads_per_job),
539
+ "OMP_WAIT_POLICY": "passive" if threads_per_job == 1 else "active",
540
+ "OMP_DYNAMIC": "FALSE",
541
+ "MKL_DYNAMIC": "FALSE",
542
+ }
543
+ advice = (
544
+ "recommended: %d job(s) x %d thread(s) each (physical cores=%d, "
545
+ "RAM %.1fGB available / %.2fGB per job -> RAM allows %d)"
546
+ % (n_jobs, threads_per_job, cores, available_ram_gb, per_job_ram_gb, max_by_ram)
547
+ )
548
+ return {
549
+ "n_jobs": n_jobs,
550
+ "threads_per_job": threads_per_job,
551
+ "cores": cores,
552
+ "per_job_ram_gb": round(per_job_ram_gb, 2),
553
+ "available_ram_gb": round(available_ram_gb, 1),
554
+ "max_by_ram": max_by_ram,
555
+ "env": env,
556
+ "advice": advice,
557
+ }
558
+
559
+
560
+ def apply_job_env(plan: dict) -> dict:
561
+ """把 plan_concurrency() 的 env 写进当前进程(给 launcher 自己用;子进程可直接用 env=)。"""
562
+ env = dict(plan.get("env") or {})
563
+ os.environ.update(env)
564
+ return env
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # 单 job 内存估算 + 训练并发规划 + 多 job 启动器
569
+ # ---------------------------------------------------------------------------
570
+ _DTYPE_BYTES = {"fp32": 4, "float32": 4, "bf16": 2, "bfloat16": 2, "fp16": 2, "float16": 2}
571
+ _OPT_BYTES_PER_PARAM = {
572
+ # 8-bit blockwise: 1-byte code x 2 states + 每 256 元素一个 fp32 absmax
573
+ "adamw8bit": 2.0 + 4.0 / 256.0,
574
+ # ademamix 8-bit: 2 个 8-bit 状态 + 1 个 8-bit 额外状态 + absmax
575
+ "ademamix8bit": 3.0 + 8.0 / 256.0,
576
+ "adamw": 8.0, # fp32 m + v
577
+ "adam": 8.0,
578
+ "sgd": 0.0,
579
+ "sgd_momentum": 4.0, # fp32 momentum
580
+ "none": 0.0,
581
+ }
582
+ _RUNTIME_GB = 0.4 # torch + CUDA-less 运行时 + Python 基线(实测约 0.35-0.40GB)
583
+
584
+
585
+ def estimate_training_memory_gb(
586
+ param_count: int,
587
+ mode: str = "full",
588
+ optimizer: str = "adamw8bit",
589
+ trainable_fraction: float = 1.0,
590
+ trainable_params: Optional[int] = None,
591
+ dtype: str = "fp32",
592
+ activation_gb: float = 1.0,
593
+ runtime_gb: float = _RUNTIME_GB,
594
+ ) -> dict:
595
+ """粗估一个 CPU 训练 job 的常驻内存(GB)。
596
+
597
+ 组成:权重(全部参数,fp32/bf16 按 dtype)+ 梯度(可训练参数)+ 优化器状态(可训练参数)
598
+ + 激活/工作区(由调用者按 batch*seq 估,默认 1GB)+ 运行时基线(默认 0.4GB)。
599
+
600
+ ``mode`` 只影响默认的 ``trainable_fraction``:
601
+ - ``full`` / ``finetune``:默认全部参数可训练(1.0);
602
+ - ``lora`` / ``efst``:默认按调用方给的 ``trainable_fraction``(如 0.005),
603
+ 冻结的基座**不产生梯度**(这是 LoRA/EFST 省内存的核心)。
604
+
605
+ 注意:这是**估算**,用于决定并发数,不是精确值;首次跑起来后用
606
+ ``tck.mem_report()`` 或 ``psutil`` 校准。
607
+
608
+ 返回 dict:``total_gb`` 以及 weights/grads/optimizer/activations/runtime 分项。
609
+ """
610
+ if param_count is None or param_count <= 0:
611
+ raise ValueError("param_count must be > 0")
612
+ mode = (mode or "full").lower()
613
+ if trainable_params is not None:
614
+ trainable = max(0, int(trainable_params))
615
+ else:
616
+ frac = 1.0 if mode in ("full", "finetune") else float(trainable_fraction)
617
+ frac = min(max(frac, 0.0), 1.0)
618
+ trainable = int(param_count * frac)
619
+
620
+ bpp = _DTYPE_BYTES.get((dtype or "fp32").lower())
621
+ if bpp is None:
622
+ raise ValueError(f"unsupported dtype {dtype!r}; use fp32/bf16/fp16")
623
+ opt_key = (optimizer or "none").lower()
624
+ if opt_key not in _OPT_BYTES_PER_PARAM:
625
+ raise ValueError(f"unsupported optimizer {optimizer!r}; "
626
+ f"choose from {sorted(_OPT_BYTES_PER_PARAM)}")
627
+
628
+ weights = param_count * bpp
629
+ grads = trainable * bpp # frozen base has no grads
630
+ opt_states = trainable * _OPT_BYTES_PER_PARAM[opt_key]
631
+ activations = float(activation_gb) * (1 << 30)
632
+ runtime = float(runtime_gb) * (1 << 30)
633
+ total = weights + grads + opt_states + activations + runtime
634
+ return {
635
+ "total_gb": total / (1 << 30),
636
+ "param_count": int(param_count),
637
+ "trainable_params": trainable,
638
+ "weights_gb": weights / (1 << 30),
639
+ "grads_gb": grads / (1 << 30),
640
+ "optimizer_gb": opt_states / (1 << 30),
641
+ "activations_gb": activations / (1 << 30),
642
+ "runtime_gb": runtime / (1 << 30),
643
+ "dtype": dtype,
644
+ "optimizer": opt_key,
645
+ "mode": mode,
646
+ }
647
+
648
+
649
+ def plan_concurrency_for_training(
650
+ param_count: int,
651
+ mode: str = "full",
652
+ optimizer: str = "adamw8bit",
653
+ trainable_fraction: float = 1.0,
654
+ trainable_params: Optional[int] = None,
655
+ dtype: str = "fp32",
656
+ activation_gb: float = 1.0,
657
+ n_jobs: Optional[int] = None,
658
+ **kwargs: Any,
659
+ ) -> dict:
660
+ """``estimate_training_memory_gb`` + ``plan_concurrency`` 的组合。
661
+
662
+ 例:0.8B 模型全参数微调(fp32 + AdamW8bit + 1GB 激活):
663
+ plan = plan_concurrency_for_training(8e8)
664
+ plan["memory_estimate"]["total_gb"] # 约 5.3GB
665
+ plan["n_jobs"] / plan["threads_per_job"] / plan["env"]
666
+ """
667
+ est = estimate_training_memory_gb(
668
+ param_count, mode=mode, optimizer=optimizer,
669
+ trainable_fraction=trainable_fraction, trainable_params=trainable_params,
670
+ dtype=dtype, activation_gb=activation_gb)
671
+ plan = plan_concurrency(n_jobs=n_jobs, per_job_ram_gb=est["total_gb"], **kwargs)
672
+ plan["memory_estimate"] = est
673
+ return plan
674
+
675
+
676
+ def spawn_training_jobs(cmds, plan: Optional[dict] = None, cwd=None, echo: bool = True):
677
+ """并行启动多个训练命令,自动给日志加 ``[jobN]`` 前缀(不会混在一起)。
678
+
679
+ Args:
680
+ cmds: 命令列表,如 ``[[sys.executable, "train.py", "--seed", "1"], [...]]``。
681
+ plan: ``plan_concurrency()`` 的返回值;其 ``env`` 会合并进每个子进程的环境变量
682
+ (OMP/MKL 线程数等)。None = 继承当前进程环境。
683
+ cwd: 子进程工作目录。
684
+ echo: True 时实时打印带前缀的日志。
685
+
686
+ Returns:
687
+ ``[(cmd, returncode), ...]``。
688
+ """
689
+ env = dict(os.environ)
690
+ if plan:
691
+ env.update(plan.get("env") or {})
692
+ procs = []
693
+ readers = []
694
+ for i, cmd in enumerate(cmds):
695
+ proc = subprocess.Popen(
696
+ cmd, cwd=cwd, env=env, stdout=subprocess.PIPE,
697
+ stderr=subprocess.STDOUT, text=True, bufsize=1)
698
+ procs.append((cmd, proc))
699
+
700
+ def _pump(p=proc, idx=i):
701
+ try:
702
+ for line in p.stdout:
703
+ if echo:
704
+ print(f"[job{idx}] {line.rstrip()}", flush=True)
705
+ except Exception:
706
+ pass
707
+
708
+ th = threading.Thread(target=_pump, daemon=True)
709
+ th.start()
710
+ readers.append(th)
711
+
712
+ results = []
713
+ for cmd, proc in procs:
714
+ rc = proc.wait()
715
+ results.append((cmd, rc))
716
+ for th in readers:
717
+ th.join(timeout=2.0)
718
+ return results
719
+
720
+
721
+ def tune_threads(step_fn, candidates=None, warmup=1, iters=2, verbose=True):
722
+ """在真实训练 step 上自动挑 ``torch.set_num_threads`` 的最优值。
723
+
724
+ 为什么需要:静态建议(物理核数 / 逻辑核×2/3)是按 GEMM 密集假设给的;混合注意力模型
725
+ (Qwen3.5 这类 GDN+全注意力)在短序列下有很多小算子和逐元素操作,实测 6 核机器上
726
+ **4 线程比 6 线程快 7~10%**(seq64: 2034 vs 2188ms;seq256: 6107 vs 6797ms)。
727
+ 所以"压榨"之前先量一下。
728
+
729
+ Args:
730
+ step_fn: 无参可调用,执行一次真实训练 step(前向+反向+optimizer step 全部);
731
+ 会重复调用,训练参数会变化,但相对耗时通常足够稳定。想更干净可以传
732
+ "只做前向+反向、不 step 优化器"的版本。
733
+ candidates: 候选线程数列表;默认 ``[2,3,4,5,6,8]`` 里不超过 CPU 核数的。
734
+ warmup: 每个候选先跑几次热机(不计时)。
735
+ iters: 每个候选计时次数,取中位数。
736
+ verbose: 打印每个候选的耗时。
737
+
738
+ Returns:
739
+ dict: ``best``(最优线程数)、``timings``({线程数: 毫秒})、``speedup_vs_worst``。
740
+ 结束时 ``torch.set_num_threads(best)``,方便直接继续训练。
741
+ """
742
+ import time as _time
743
+
744
+ import torch
745
+
746
+ import statistics as _st
747
+
748
+ cores = os.cpu_count() or 1
749
+ if candidates is None:
750
+ candidates = [n for n in (2, 3, 4, 5, 6, 8) if n <= max(1, cores)]
751
+ candidates = [int(n) for n in candidates if int(n) >= 1]
752
+ if not candidates:
753
+ candidates = [1]
754
+
755
+ timings = {}
756
+ for n in candidates:
757
+ torch.set_num_threads(n)
758
+ for _ in range(max(0, warmup)):
759
+ step_fn()
760
+ samples = []
761
+ for _ in range(max(1, iters)):
762
+ t0 = _time.perf_counter()
763
+ step_fn()
764
+ samples.append(_time.perf_counter() - t0)
765
+ timings[n] = _st.median(samples) * 1e3
766
+ if verbose:
767
+ print("[torch_cpu_kit] threads=%d -> %.1f ms/step" % (n, timings[n]))
768
+
769
+ best = min(timings, key=timings.get)
770
+ torch.set_num_threads(best)
771
+ worst = max(timings.values())
772
+ if verbose:
773
+ print("[torch_cpu_kit] best threads=%d (%.1f ms/step, %.1f%% faster than worst)"
774
+ % (best, timings[best], 100.0 * (1.0 - timings[best] / worst)))
775
+ return {
776
+ "best": best,
777
+ "timings": timings,
778
+ "speedup_vs_worst": (worst / timings[best]) if timings[best] else 1.0,
779
+ }
780
+
781
+
456
782
  if __name__ == "__main__": # 自检:python torch_cpu_kit.py
457
783
  apply_env(verbose=True)
458
784
  import torch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitsandbytes-cpu-fork
3
- Version: 0.50.2.dev4
3
+ Version: 0.50.2.dev5
4
4
  Summary: bitsandbytes with a pure-CPU training backend (AVX2 / NEON): 8-bit optimizers, fused GDN, gemm_8bit, disk balancer. No GPU required.
5
5
  License-Expression: MIT
6
6
  Project-URL: repository, https://github.com/Ganyvnanoaa09-0906/bitsandbytes-CPU
@@ -336,7 +336,7 @@ code and the Transformers / PEFT / Diffusers integrations keep working as-is:
336
336
 
337
337
  ```python
338
338
  import bitsandbytes as bnb # the fork, not upstream
339
- print(bnb.__version__) # 0.50.2.dev4
339
+ print(bnb.__version__) # 0.50.2.dev5
340
340
  ```
341
341
 
342
342
  Building from source instead (any platform) is described in
@@ -381,7 +381,7 @@ build_manual\build_manual.bat amd # (in VS x64 Native Tools prompt)
381
381
 
382
382
  > `.gitattributes` 强制 C/C++ 源码与构建脚本用 LF 换行,clone 到 Windows/Linux 均可
383
383
  > 直接编译(CRLF 会让 MSVC 吞行、Linux `g++` 报 `bad interpreter`)。
384
- > `disk_balancer`(`--flash`)跨平台:Windows 用 ctypes,Linux/WSL 用 psutil。
384
+ > `disk_balancer`(`--flash`)跨平台:Windows 用 ctypes;Linux 用 `/proc/mounts` + psutil + `/sys/block/*/queue/rotational`;**WSL 已支持**——缓存只落在 WSL 自己的 ext4(默认 `~/.cache/bnb_flash_cache`),9P 宿主盘 `/mnt/[盘符]`、网络盘、只读挂载、tmpfs 一律拒绝(显式 `--flash_paths` 指过去会直接报错)。零拷贝写盘队列有在飞字节上限(`max_inflight_bytes`),卸载不会把源张量内存偷偷留住。
385
385
 
386
386
  ### Prebuilt wheels (optional)
387
387
 
@@ -1,5 +1,5 @@
1
1
  bitsandbytes_cpu_cli.py,sha256=rSRi1aba5Ztr5HGrC0is8wG_hWxJx2W8CD9FD-rm96U,50540
2
- bitsandbytes/__init__.py,sha256=YtR8VzxzRqklpunsOn67EzEb4MHxjv2CXaXC4miHSBs,2370
2
+ bitsandbytes/__init__.py,sha256=8HcKBjAwoTyG93Vv0f6JYEAfh4vcLi8InNzYJYq7O3g,2370
3
3
  bitsandbytes/__main__.py,sha256=A3iA9QH8yfn-60uN6w_7WUiuRIM777rZ_pf_HsZtse4,90
4
4
  bitsandbytes/_cpu_caps.py,sha256=LgG3yRHdf0YGxTACxrweqiunghmuJbDAol4s02mwmHA,4691
5
5
  bitsandbytes/_ops.py,sha256=VrOBIbain2ftHpBgxUHkf8fRpq1RCnsFi16tS-YGj6E,20640
@@ -8,13 +8,13 @@ bitsandbytes/cli.py,sha256=_nF8KmWvu2jqqpoGvTf-spt2GxA7F11TstTK3FBXbgE,1125
8
8
  bitsandbytes/consts.py,sha256=xfDadQAax-maxqkHrVNbZ6UITJoS0XgcUfopmjLgilM,380
9
9
  bitsandbytes/cuda_specs.py,sha256=akFTR2ZxQ5CFpFLeg5QKFbVOZ4xz3USax43RxazP22k,3236
10
10
  bitsandbytes/detect_cpu.py,sha256=7L0QgAU_a6w6Ckm33vnaLgUwqAhU6ce9pQ1fwkk52Y4,8071
11
- bitsandbytes/disk_balancer.py,sha256=fPh0bAXrREaDMrRd80tuk1TqG9tEhQB14s9-nJ2QLmk,54551
11
+ bitsandbytes/disk_balancer.py,sha256=lyNgcqfBSsoflBDMffXKA4oNld4jBTbzugja988IN9w,74135
12
12
  bitsandbytes/efst.py,sha256=D2U8NACi_p_qJi2-_xbi3EZblPWQDhaX-h7hXO1iKMs,30772
13
13
  bitsandbytes/functional.py,sha256=chLY-f592gXbSVOHA78ME3d-6Jq8rU3d2xJwx3GYDII,66814
14
14
  bitsandbytes/gdn_cpu.py,sha256=QT5pdWwxPob6SL33wj4kjJLGnIkApivxvXjn7a--8Ys,27412
15
15
  bitsandbytes/libbitsandbytes_cpu.dll,sha256=DZrZqVfXiCtJaQEI_NNkCFLIbbxBYMYJBgSA35Zu1ao,375808
16
16
  bitsandbytes/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- bitsandbytes/torch_cpu_kit.py,sha256=r5oAp4_5p5FiCitLn1EXMcs4veTGQ9e6abIyGtlYJ34,19677
17
+ bitsandbytes/torch_cpu_kit.py,sha256=P5Bk5jI86sT3Z2cleWDwv9XV0ydZ2rPflNdxOP2zzPU,32844
18
18
  bitsandbytes/utils.py,sha256=G1oZbgIAt07m9-7SpLBRtIjsipI8kCptdfTWKz96g9I,6928
19
19
  bitsandbytes/vcomp140.dll,sha256=ldTOSmgC0eGLXg4XIsww6nLKfgM_g4KPBcC3uZP-fL8,212920
20
20
  bitsandbytes/autograd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -22,7 +22,7 @@ bitsandbytes/autograd/_functions.py,sha256=d8pUyHtprUcKxz5dfd6UlI8g3p3ouHZJV3KLw
22
22
  bitsandbytes/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
23
  bitsandbytes/backends/utils.py,sha256=3FZPL78T26gTiKI9TIcWfdVNoEr48XsNtSaF_kKxHIQ,2322
24
24
  bitsandbytes/backends/cpu/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
- bitsandbytes/backends/cpu/ops.py,sha256=nJEHrWcE_-cbGjvEV2R5wIyTYpE3C4Xn3damqb68caY,32552
25
+ bitsandbytes/backends/cpu/ops.py,sha256=uDGAbpa8d9Wv-wf1QMxG5rsWO5NKAoaN8cR1T2-7zm4,33290
26
26
  bitsandbytes/backends/cuda/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
27
  bitsandbytes/backends/cuda/ops.py,sha256=1Y-goMBT1aDs0ek9hHUx1eAiagvYBIRy7gymMkZMwg8,42583
28
28
  bitsandbytes/backends/default/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -56,9 +56,9 @@ bitsandbytes/optim/lion.py,sha256=OUu1aysIM85NNHNFu-LDTCLI6HoqPNMp-bLVgZAGtHk,83
56
56
  bitsandbytes/optim/optimizer.py,sha256=LgegbqBzbk5s57uCEgcmSqXC5wRHGMiAIbe646Fd3y4,28127
57
57
  bitsandbytes/optim/rmsprop.py,sha256=2sHSY1hfTr83aZvBmIEfzoTiKER8LZc_BDkmyJG3lPs,6134
58
58
  bitsandbytes/optim/sgd.py,sha256=gVjbjjYrm197RiOt6vLoXaMxaWVK6De91Ap_KL6WFtg,4916
59
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/licenses/LICENSE,sha256=UkEte8fOQVfqYou6rLiCngqcs8WPV_mRdhJryM8r_IU,1086
60
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/METADATA,sha256=ZZgwx21oAhUgo7OHgPO6eXPVb7jbqMZZ3GATF2Io_dI,18683
61
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/WHEEL,sha256=5s7VO_HUPfjwcO4LINqxGe8JSFZAGgaurQKPGA4cx_g,97
62
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/entry_points.txt,sha256=GqLu5Rm-j-9_CvhqX0jVecyJShA1XGmxRoPEiibJpcI,99
63
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/top_level.txt,sha256=l9ip7vjQq1JV6CB_ZGntWtjfxpkH119NDxupnJho9k0,34
64
- bitsandbytes_cpu_fork-0.50.2.dev4.dist-info/RECORD,,
59
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/licenses/LICENSE,sha256=UkEte8fOQVfqYou6rLiCngqcs8WPV_mRdhJryM8r_IU,1086
60
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/METADATA,sha256=T92ZHoDNh9Wb4aabyHdOzrnizF0ndnbLrfO2An0rARQ,19077
61
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/WHEEL,sha256=5s7VO_HUPfjwcO4LINqxGe8JSFZAGgaurQKPGA4cx_g,97
62
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/entry_points.txt,sha256=GqLu5Rm-j-9_CvhqX0jVecyJShA1XGmxRoPEiibJpcI,99
63
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/top_level.txt,sha256=l9ip7vjQq1JV6CB_ZGntWtjfxpkH119NDxupnJho9k0,34
64
+ bitsandbytes_cpu_fork-0.50.2.dev5.dist-info/RECORD,,