89 Commits
Author SHA1 Message Date
ViperEkura 8f89c82d55 chore: bump version to 1.3.9 2026-07-12 21:04:20 +08:00
ViperEkura 21871197d7 refactor: use actual q_len from input, remove dead num_splits init 2026-07-12 19:48:17 +08:00
ViperEkura 4c35d36146 fix: auto-assign free port in spawn_parallel_fn to avoid EADDRINUSE 2026-07-12 19:21:56 +08:00
ViperEkura 9aca62c26c perf: remove per-element sentinel checks in softmax + fix stale comments
- Replace 4*NC8 per-element -FLT_MAX comparisons with 2 row-level pn guards; masked entries naturally underflow via expf(-FLT_MAX - nm) ≈ 0; pn only guards all-masked-row edge where nm == -FLT_MAX (exp(0)=1 not 0); ~1-3% speedup on prefill (verified via standalone CUDA bench); correctness verified: prefill 4/4, decode 3/3, paged decode 13/13
- Fix stale comments: remove false 'pre-scale Q' claim, correct occupancy numbers, remove phantom sQ from smem description
2026-07-12 15:45:08 +08:00
ViperEkura b5cdea98ad refactor: remove ineffective __launch_bounds__ from prefill kernel
- Remove MIN_BLOCKS template param and __launch_bounds__ attribute
- Profiling shows smem (not registers) is the occupancy bottleneck for D>=64, making the hint a no-op
- D=64 sees 2-4% speedup, D=128 unchanged (smem-capped at 1 block/SM)
- Update comment blocks in kernel header and both dispatch sites
- Verified correctness via standalone CUDA test (max_err ~1e-4)
2026-07-12 15:00:53 +08:00
ViperEkura 69fecaf387 perf: double-buffer KV pipeline and Q direct-to-register in decode
- Double-buffered KV (STAGES=2) for D<=128: next tile cp.async overlaps current tile MMA compute, hiding global load latency
- Q loaded directly from global into mma A-operand registers, removing sQ staging and prologue syncwarp
- Predicated cp.async unifies full and partial tile paths, eliminating scalar fallback branch
- STAGES=1 fallback for D=256 (double-buffer would exceed smem budget)
- Applied to both contiguous and paged decode MMA kernels
- ~1.27x average speedup on L20 (sm_89), zero precision loss
2026-07-12 14:14:54 +08:00
ViperEkura fd6d25ad86 refactor: extract bench_kernel and dispatch_by_head_dim into test_utils 2026-07-12 00:05:04 +08:00
ViperEkura 2c3cef1c87 feat: wire up paged decode CUDA kernel to Python extension
- Add attn_paged_decode wrapper in ops.py with gather fallback
- Register kernel in loader.py and export from __init__.py
- Extract test_utils.cuh shared by all attention unit tests
- Rename attn_paged_vs_contiguous.cu to attn_paged_decode_test.cu
- Refactor decode/prefill tests to use common bf16 helpers and cpu ref
- Fix k_cache dim check in attn_paged_decode.cu
2026-07-11 18:40:49 +08:00
ViperEkura 89ece26c25 feat: paged decode attention with split-KV (scalar + MMA)
- PagedAttentionParams merged into attn_common.h
- Scalar variant: warp-per-query-head split-KV, resolves page table per-position for the K/V shared-memory tile load
- MMA variant (sm_80+): tensor-core head-packing with cp.async, single page-table lookup per tile (BC=32 fits within page_size>=32)
- Standalone test: 14 cases across head_dim 32/64/128/256, GQA, multi-batch, both paths verified against CPU reference
2026-07-11 18:14:51 +08:00
ViperEkura 2c0b5d0b5e perf: enable MMA decode path for G=1 full attention
- inline decode_use_mma() into dispatch_decode()
- drop G>1 guard, MMA works correctly for G>=1
- decode is memory-bound, tensor cores + cp.async still win at G=1
2026-07-11 11:45:13 +08:00
ViperEkura a4ae7d17fb perf: increase decode split-K parallelism for short sequences
- Remove tiles_total/8 min-work cap that limited splits for small workloads
- Simplify decode_num_splits to only use base_blocks and tiles_total
- Short sequences now generate more blocks, improving SM utilization
2026-07-11 11:26:42 +08:00
ViperEkura 8a8550184f refactor: template AttentionParams, rename .cuh to .h
- Convert AttentionParams to a template struct supporting arbitrary types
- Rename attn_common.cuh -> attn_common.h (no CUDA-specific code remains)
- Include standard headers explicitly in each .cuh instead of via attn_common.cuh
- Allow .h files in csrc/ via .gitignore
2026-07-11 11:03:14 +08:00
ViperEkura b8b439b713 perf: fuse decode combine kernel to single-pass online-rescale reduction
- Replace 3-scan loops (mstar, lstar, acc) with 1-pass online rescale
- Halves __expf calls (num_splits vs 2*num_splits) and ml_part re-reads
- Mathematically equivalent, no change to o_part traffic or output
2026-07-11 00:24:09 +08:00
ViperEkura 41cd40363a perf: post-multiply attention scale in float instead of pre-scaling Q in bf16
- Replace bf16 pre-scale Q loading with direct 32-bit aligned bf16x2 reads
- Apply scale in float32 after Q@K^T, before online softmax
- Reduces causal max error from 2^-6 to 2^-8 with zero perf cost
2026-07-11 00:13:32 +08:00
ViperEkura d923ebe38d refactor: rename gqa_* to attn_*, split-KV for all decode paths
- Rename all csrc/kernels/gqa_*.cuh/cu to attn_*, with _split_q / _split_kv
  strategy suffix and optional _mma compute suffix
- Remove non-split MMA decode kernel, keep only split-KV path
- Convert scalar decode fallback to split-KV (o_part/ml_part + combine)
- Move combine kernel to attn_decode_split_kv.cuh (shared by both paths)
- Rename GQAParams to AttentionParams
- Update all C++ #include, PYBIND11, and Python extension references
2026-07-10 23:35:14 +08:00
ViperEkura 29b0423c4e refactor: deduplicate kernel code with shared MMA and entry-point helpers 2026-07-10 20:52:27 +08:00
ViperEkura 88f8dca2c2 perf: enlarge prefill KV tile to BC=32 for D<=128
- Kernel is latency-bound (25% occupancy), not compute/bandwidth-bound
- BC=16 wasted a cp.async wait + barrier + loop overhead per tiny tile
- Double KV tile to BC=32 for D<=128; D=256 stays 16 (64KB > 48KB smem cap)
- Retune MIN_BLOCKS per head_dim (32->6, 64->4, 128->3, 256->2)
- Result: ~6-8% faster on L20, 0.93-1.20x vs torch SDPA, correctness unchanged
2026-07-10 17:43:23 +08:00
ViperEkura 9027fdc546 test: bench production MMA attention path with FLOP/s and bandwidth 2026-07-10 16:58:34 +08:00
ViperEkura cbd140340d perf: load prefill Q fragments directly from global (drop sQ staging)
- read the 8 Q elements each lane needs straight from global into the mma
  A-operand layout, pre-scaled, instead of staging through shared sQ
- removes the sQ smem area (20KB->16KB) and the serialized per-warp prologue
  with its WARPS __syncthreads barriers
- result vs torch SDPA: prefill 0.70-0.82x -> 0.85-1.00x (matches torch at
  seq=128; 2048 1.251->1.159ms), correctness unchanged across head dims
2026-07-10 12:27:45 +08:00
ViperEkura 988e01314d perf: pipeline prefill MMA kernel (double-buffered K/V + packed stores)
- double-buffer K/V one tile ahead via cp.async to overlap load with tensor-core math (ncu long_scoreboard 2.12->0.53)
- reorder wait->barrier->prefetch so one __syncthreads/tile covers both cross-warp publish and buffer-reuse (was two)
- add predicated cp_async_16_pred (src-size=0 zero-fills OOB) to unify full/partial tiles, dropping the scalar fallback
- halve BC to 16 to keep 3 blocks/SM despite the doubled smem
- pack adjacent bf16 output into one 32-bit STG, removing the uncoalesced scalar-store penalty (14%->5% sectors)
- result vs torch SDPA: prefill 0.61-0.78x -> 0.70-0.82x, spills eliminated
2026-07-10 12:19:14 +08:00
ViperEkura 7ba43a7c6f perf: add split-K (FlashDecoding) to decode MMA kernel
Decode has only batch*kv_head independent tasks, so the grid was tiny (e.g. 16 blocks) leaving most SMs idle (ncu: 0.04 waves/SM, 11% DRAM).

- Partition KV across gridDim.z blocks emitting unnormalised (O, m, l) partials, reduced by a new combine kernel
- Choose split count to fill the device (~2 blocks/SM), capped by tile count and 32; fall back to single-pass direct-write when batch*kv_head already saturates the SMs
- Refactor decode dispatch into named helpers, de-duplicate scalar fallback

Result: now DRAM-bound at 63% (99->543 GB/s), 2.1-2.5x over torch SDPA in the low-parallelism regime, on par at high parallelism
2026-07-10 11:43:18 +08:00
ViperEkura dea59f7e1d fix: restore resident Qa to fix sQ overwrite bug
Moving Qa ldmatrix into the tile loop caused warps 0-2 to read
warp 3's Q data from sQ (only the last warp's data survives the
serialized load loop). Reverted to loading Qa during the init phase
and keeping it resident; __launch_bounds__ still forces 128 regs
(33% occupancy) with spill to local memory.
2026-07-10 00:49:58 +08:00
ViperEkura 85dc771460 perf: reduce MMA kernel registers, switch to static smem
- Move Qa[KD][4] into tile loop (reload from sQ per tile)
  cutting ~32 resident registers for HEAD_DIM=128
- Replace extern __shared__ with static template-sized smem
  (no cudaFuncSetAttribute or dynamic allocation needed)
- Add __launch_bounds__ with MIN_BLOCKS param, dispatch by HEAD_DIM
  (hd=128→4, hd=64→6, hd=32→6)
- Remove dynamic smem from scalar kernel and C test
- Result: hd=128 168→128 regs, 25%→33% occupancy
2026-07-10 00:39:47 +08:00
ViperEkura 2c5629b81d docs: fix documentation errors across README and assets/docs
- Correct training CLI args: remove non-existent --adamw_beta1/2, fix --weight_decay
- Fix optimizer section: document MuonMix instead of plain AdamW
- Fix inference.md decode phase description (all groups, not largest)
- Fix dataflow.md H5Store description (no share_memory_ in code)
- Fix architecture.md class diagram: remove Task.stream_callback,
  EncoderConfig.use_gated_attention; update stop_ids docs
- Fix --min_rate default value description to match code (0.01)
- Update all document timestamps to 2026-07-09
2026-07-09 10:09:53 +08:00
ViperEkura 841a582b28 refactor: split mask builder by single/multi output
- Extract SingleOutputMaskBuilder for SFT and pretrain configs
- Extract MultiOutputMaskBuilder for DPO and GRPO configs
- Keep SectionedMaskBuilder as backward-compatible facade
- Register "single" and "multi" names in MaskBuilderFactory
- Add parity and rejection tests for concrete builders
2026-07-08 21:18:34 +08:00
ViperEkura c8567a6f65 fix: exclude embedding, lm_head, bias, and norm params from Muon optimizer, use AdamW 2026-07-08 19:42:20 +08:00
ViperEkura 8035be9b1f fix: make MuonMix inherit from torch.optim.Optimizer 2026-07-08 17:03:05 +08:00
ViperEkura e9b03f4fca perf: apply cp.async, XOR swizzle, pre-scaled Q to decode MMA kernel
Decode MMA kernel previously used scalar global→shared loads with
LD=HEAD_DIM+8 padding and per-tile scale multiply. This commit brings it
in line with the prefill MMA kernel (which already had these optimizations):

- cp.async K/V loads (bypasses registers, halves load instructions)
- XOR swizzle: LD=HEAD_DIM instead of HEAD_DIM+8 (zero waste smem)
- Pre-scale Q during load (removes per-tile scale multiply in softmax)
- Clean up prefill MMA kernel comments (no code change)

~2x speedup on decode (0.47ms→0.24ms at seq_len=512)
2026-07-08 16:15:14 +08:00
ViperEkura fd65b9bc23 feat: support HEAD_DIM=32 and split extension into loader/ops
- add case 32 to decode/prefill dispatch switch
- fix swiz_col out-of-bounds for HEAD_DIM=32: XOR mask now limited to chunk count (3 for 32, 7 for >=64) instead of always 7, which produced column offsets >= LD=32 and corrupted shared memory
- restructure decode dispatch to #ifndef/#else/#endif matching prefill
- split astrai/extension/__init__.py into loader.py (kernel .so discovery) and ops.py (wrapper functions + torch SDPA fallback); __init__.py now re-exports the public API
2026-07-08 14:14:11 +08:00
ViperEkura 9ebaea840f perf: cp.async K/V loads, shared sQ staging, causal skip, XOR swizzle
- cp.async global→shared for K/V full-tile loads, eliminates 99.6% of shared-store bank conflicts (612K→2.7K per ncu)
- add cp_async_16/commit/wait_all/wait_group<N> helpers in mma utils
- shared sQ staging (single area, serialized per-warp load), cuts smem from (2*BC + WARPS*BR)*LD to (2*BC + BR)*LD bf16
- pre-scale Q by attention scale during Q load, removes per-tile scale multiply in softmax loop
- causal tile skipping: block-level early break + warp-level skip
- scalar fallback only for last partial tile
- XOR swizzle (swiz_col) at 8-bf16 chunk granularity, eliminates ldmatrix bank conflicts without LD padding, LD=HEAD_DIM (zero smem waste), saves 1280 bytes/block vs HEAD_DIM+8 padding
2026-07-08 12:30:32 +08:00
ViperEkura 6adc221c10 refactor: extract shared MMA utils into gqa_mma_utils.cuh
- Move mma16816, ld2, pk2, pkb, ldmatrix_x4/x2/x2_trans to shared header
- gqa_prefill_attn_mma.cuh and gqa_decode_attn_mma.cuh both include it
2026-07-07 23:01:15 +08:00
ViperEkura 9e63cb9ed0 feat: MMA head-packing decode kernel with scalar fallback dispatch
- Add gqa_decode_attn_mma.cuh for tensor-core decode path
- Add dispatch_decode<> selecting MMA vs scalar based on G and mask
- Add TORCH_CHECK for unsupported head_dim instead of silent scalar launch
2026-07-07 22:56:02 +08:00
ViperEkura 4225518cf3 perf: add fast-math and vectorization nvcc/cxx build flags
- centralize CXX_FLAGS/NVCC_FLAGS in csrc/build.py as single source
- add --use_fast_math, --ptxas-options=-O3,-v, --extra-device-vectorization
- add -march=native -funroll-loops host flags
- setup.py reads shared cxx_flags/nvcc_flags from registry
- sync pure-C test build commands with new flags
2026-07-07 22:28:32 +08:00
ViperEkura c50adbaac0 feat : replace AdamW with MuonMix (Muon + AdamW) optimizer
- Muon for 2D matrix params, AdamW for 1D (norm/bias/embed)
- MuonMix wrapper handles combined step/zero_grad/state_dict
- New CLI args: weight_decay, muon_momentum, muon_nesterov, muon_ns_steps, muon_adjust_lr
- Removed adamw_beta1/adamw_beta2/adamw_weight_decay
- Moved optimizer/strategy params from signature to **kwargs
2026-07-07 14:10:36 +08:00
ViperEkura 536dbc0c9a fix: set tqdm postfix before update so first step shows metrics 2026-07-07 00:14:13 +08:00
ViperEkura 4af7acd449 fix: support single .h5 file loading in load_h5 2026-07-07 00:11:16 +08:00
ViperEkura 53ed52b4b8 refactor: extension dispatch layer with CUDA/torch fallback
- Add gqa_decode_attn/gqa_prefill_attn dispatch functions
- Internal _available/__modules with underscore prefix
- CUDA kernel path with F.scaled_dot_product_attention fallback
- GQA head expansion in fallback path
2026-07-06 21:07:16 +08:00
ViperEkura f1cc7cedce feat: ldmatrix + smem padding for mma prefill kernel
- replace scalar fragment loads with ldmatrix.sync.x4/x2
- add smem row-stride padding (LD = HEAD_DIM + 8) to eliminate 8-way bank conflicts from HEAD_DIM being a 32-bank multiple
- switch build flag from positive to negative: -DASTRAI_NO_MMA for pre-sm_80 only; mma is the default path
- vectorize scalar path smem loads with float4 ld8
- fix pure-C test configs for ld8 alignment
2026-07-06 20:55:22 +08:00
ViperEkura ddc4bd1cf6 feat: tensor-core mma prefill with build-time dispatch
- add register-resident flash-attention kernel using mma.sync.m16n8k16
- dispatch mma vs scalar at build time: pre-sm_80 defines
  -DASTRAI_NO_MMA, else defaults to mma
- scalar path vectorized with float4 smem loads (ld8)
2026-07-06 20:33:24 +08:00
ViperEkura cc36530c73 perf: group-split register-blocking gqa_prefill kernel
- one query row per group of G=8 lanes, each owning HEAD_DIM/G dims of qreg[]/acc[] in registers
- removes full 32-lane warp_reduce_sum; S dot reduces over only G lanes
- templated on <HEAD_DIM,G,ROWS,P_BC>, block=(G,ROWS)=(8,32)
- per-group shuffle mask so causal loop-bound divergence doesn't deadlock the shuffle
- update pure-C test to the templated launch
2026-07-06 18:33:08 +08:00
ViperEkura 11fa807cfc fix: correct prefill mask index, unify GQA kernel interface
- Fix mask indexing: batch*q_len*kv_len -> batch*kv_len
- Add csrc/kernels/gqa_common.cuh with shared GQAParams struct
- Unify decode/prefill Python API: both accept (q,k,v,mask=None,...)
- Decode now supports optional mask, is_causal, causal_offset, scale
- Rename struct fields: B->batch, Hq->q_head, Hk->kv_head, D->head_dim
- Use py::arg() for correct None/defaults handling in pybind11
- Update pure C tests and build instructions (-arch=sm_89)
2026-07-06 17:21:23 +08:00
ViperEkura bcdd93e0eb feat: split kernel defs from bindings, add prefill tiled kernel and pure C tests
- Split .cuh/.cu for gqa_decode_attn and gqa_prefill_attn
- gqa_prefill_attn: tiled shared-memory K/V, fused load, compute-opt, mask support
- Add pure C tests under csrc/tests/ for fast nvcc-only iteration
- Update .gitignore for build artifacts
2026-07-06 16:14:55 +08:00
ViperEkura 579b8c3129 fix: correct gqa_decode_attn reduction + add gqa_prefill_attn
- gqa_decode_attn: rewrite to per-KV-head, K in smem
- gqa_prefill_attn: new kernel for Q_len > 1 with GQA
2026-07-06 13:45:18 +08:00
ViperEkura d7da51569f docs: update install instructions in EN/CN README 2026-07-06 12:25:36 +08:00
ViperEkura e8e228d035 feat: add optional CUDA kernel system (csrc/) + fused GQA decode attention
Structure:
  csrc/               -- .cu sources + build.py registry
  astrai/extension/   -- compiled .so + __init__.py (import dispatcher)
  setup.py            -- CUDAExtension from csrc/build.py REGISTRY

Control: CSRC_KERNELS=true|false env var at install time.
Fallback: astrai.extension.available dict for runtime detection.
2026-07-06 12:09:58 +08:00
ViperEkura 2579658e15 chore : shields release badge from /release to /tag 2026-07-05 20:34:40 +08:00
ViperEkura f0cd0134c6 fix : update benchmark for v1.3.8 cache API, add argparse and cache type switch
- Replaced old KVCage API with PageCache/ContiguousCache
- Added --cache contiguous|paged switch for decoding comparison
- Added argparse for all params (batch/prompt/gen/device/dtype)
- Fixed PageCache decode crash by extending pages for full sequence
2026-07-05 20:30:26 +08:00
ViperEkura abb96996f8 docs : sync 6 doc files to actual code
- architecture.md: removed TrainConfig.log_interval, split KVCache into
  PageCache/ContiguousCache with CacheView/PageCacheView/ContiguousCacheView,
  added JsonlStore, fixed GradientCheckpointingCallback type,
  CheckpointCallback typo, ProgressBarCallback hooks
- training.md: added position_ids to SFT keys, fixed callback hook table,
  removed merged ValidationCallback
- inference.md: documented ContiguousCache default vs PageCache paged
- dataflow.md: added JsonlStore to storage backends and format detection
- params.md: removed nonexistent --log_interval
- preprocessing.md: updated timestamp
2026-07-05 19:35:18 +08:00
ViperEkura bbe6ff2d8f release : v1.3.8
- refactor: 重写 IFD 评估为三层架构,引入 BFD 装箱与自定义 attention mask 批处理打分
- refactor: 重写 HumanEval 评估为函数式流水线,修复测试超时与动态 pass@k
- perf: 替换 paged KV cache 为 ContiguousCache,解码所有 group
- feat: 新增 ROUGE 评估脚本、JSONL 数据集 store、stream_chat 参数
- fix: 修复 IFD token-set 不对称、SFT position_ids 默认值、文档边界保留
2026-07-05 19:12:33 +08:00
ViperEkura db9b39b084 fix: resolve IFD token-set asymmetry and support single-token answers
- Sentinel-anchored unconditional pass: both branches now predict the same N response tokens
- Single-token responses (rl=1) fully supported
- ctx_len tracked per sample; skip_reason replaces silent None
- --per_token flag for per-token IFD breakdown
2026-07-05 17:48:26 +08:00
ViperEkura 849e1e00a3 refactor: clean up inference design patterns
1. KVCache base: add default task_cached/task_record_hashes, remove getattr from scheduler
2. Remove page_size param from scheduler constructor (ContiguousCache-only)
3. InferenceEngine expose cache param for KVCache injection
4. Rename page_cache -> kv_cache in Executor
5. Move stream_callback from Task to TaskManager._callbacks dict
6. TaskManager.clear_queues clears callbacks
2026-07-05 11:41:54 +08:00
ViperEkura 5416c2e8fb perf: replace paged KV cache with contiguous ContiguousCache, decode all groups
- Add KVCache/CacheView abstract base classes in cache.py
- Add ContiguousCache (contiguous per-slot buffer, default) alongside PageCache (paged, renamed from old KVCache)
- Merge make_table_tensor + bind into bind_tasks on KVCache interface
- Remove task_cached/task_record_hashes from base class (PageCache-only)
- Scheduler: decode all position groups instead of just the largest (eliminates 63% group skip rate)
- Scheduler: accept optional cache param for swapping implementations
- Model layer type hints use CacheView base class
- Batch 1-32: 1-7% speedup from eliminating Storage.gather overhead
- All 183 inference tests pass
2026-07-05 11:34:36 +08:00
ViperEkura 599a51f4f7 fix: reliable test timeout, separate generate/test phases, dynamic pass@k
- Replace SIGALRM+exec() with subprocess.run(timeout=) for test execution
- Add --test_only flag to skip generation and test existing completions
- Add --generate_only flag for generation-only runs
- Derive pass@k values from num_samples (filter k > n)
- Support loading completions from array JSON (not just JSONL)
2026-07-05 08:47:30 +08:00
ViperEkura 17d6eaa2f2 refactor: rewrite humaneval evaluation with functional pipeline design
- fix KeyError race condition in inference cache touch()
- EvalConfig dataclass for centralized configuration
- load->generate->extract->test->score->report pipeline
- two-phase generation+testing for max GPU utilization
- signal-based SIGALRM timeout protection for code exec
- suppress subprocess stdout/stderr pollution
2026-07-05 07:58:28 +08:00
ViperEkura 2d908639e9 feat : add ROUGE evaluation script (manual impl, no deps)
- ROUGE-1/2 via n-gram overlap (Counter)
- ROUGE-L via LCS (DP)
- CLI: python scripts/eval/evaluate_rouge.py --data_path ... --output ...
- Library: compute_rouge(ref, cand) -> dict of precision/recall/f1
2026-07-05 01:15:01 +08:00
ViperEkura c7158418dd perf: add BFD bin-packing and custom attention mask to IFD batch scoring 2026-07-04 18:58:13 +08:00
ViperEkura 4d3c9341c1 refactor: rewrite IFD evaluation with clean three-layer architecture 2026-07-04 18:33:51 +08:00
ViperEkura 4e508afa2d fix : SFT pipeline position_ids default & doc boundary preservation
- change position_ids_mode default from "none" to "doc_reset" so SFT preprocessing always generates position_ids (was causing dataset load KeyError)
- generate per-doc position_ids before packing (doc_reset mode), preserving document boundaries for BFD packing (cross-doc attention leak fix)
- change _align_bucket padding from [1] to [0] to avoid accidentally training on loss_mask padding
2026-07-04 15:59:11 +08:00
ViperEkura 8999ca89b8 feat: add JSONL dataset store with on-the-fly tokenization
- Add JsonlStore registered under "jsonl" in astrai/dataset/storage.py
- Reuse PipelineConfig schema for JSONL dataset configuration
- Update detect_format to recognize JSONL directories and files
- Move save_h5/load_h5/save_bin/load_bin to astrai/serialization
- Split astrai/serialization.py into checkpoint/dataset submodules
- Add tests for JSONL detection, seq/SFT stores, and config roundtrip
2026-07-04 15:42:33 +08:00
ViperEkura 1adca39cd8 fix: handle long sequences and optimize IFD computation 2026-07-04 08:35:45 +08:00
ViperEkura 204873fa2f fix: handle long sequences and optimize IFD computation 2026-07-04 08:23:32 +08:00
ViperEkura a5c1de6b1b feat: add model_path temperature top_p top_k max_tokens system_prompt args to stream_chat 2026-07-04 07:33:32 +08:00
ViperEkura 27524ad085 fix: reset sampler iter at epoch end so progress bar shows total after first epoch 2026-07-04 06:35:55 +08:00
ViperEkura 27d1921d9c fix: scheduler division-by-zero, loss_mask bool
- schedule.py: guard warmup_steps/lr_decay_steps against zero
- strategy.py: use ~loss_mask instead of loss_mask==0 on bool tensor
2026-07-03 22:04:55 +08:00
ViperEkura 70c0e5de90 refactor: merge validation into MetricCallback, simplify progress bar to optimizer steps
- Remove separate ValidationCallback, merge into MetricCallback
- Progress bar now tracks optimizer steps instead of micro-steps
- Remove unused log_interval config field and CLI flag
- Fix validation all_reduce: use SUM(loss, count) instead of AVG
- Simplify metric logging: always log every optimizer step
- Add grad_norm display to progress bar
2026-07-03 21:43:08 +08:00
ViperEkura dfb151537b fix: ForwardRef._evaluate Python 3.12 compatibility 2026-07-03 18:41:19 +08:00
ViperEkura 500c605fad fix: unify scheduler min_rate default to 0.01, clamp WSD warmup 2026-07-03 17:52:23 +08:00
ViperEkura dc9faca3b1 fix: align docs with actual code (40+ inconsistencies)
- Remove nonexistent Muon class from architecture diagram
- Fix Checkpoint/TrainConfig/TrainContext field names (iteration -> consumed_samples, start_batch -> start_samples)
- Add missing fields: neftune_alpha, val_split, grad_norm, optimizer_step, tool_calls/tools
- Fix CLI param defaults: --log_interval 1, --metrics [loss,lr,grad_norm], --start_samples
- Add missing scheduler CLI params; remove nonexistent --num_workers from preprocess docs
- Fix inference SSE format, stats response keys, error codes to match actual server output
- Fix preprocessing docs: BOS once, shard_0000 layout, from_json->from_file, GRPO prompts_mask
- Fix dataflow detect_format/_normalize descriptions; correct callback order in training.md
2026-06-30 20:47:23 +08:00
ViperEkura aabb0d83e9 refactor : replace iteration with consumed_samples
- Replace context.iteration with consumed_samples (global sample count)
- Add optimizer_step property derived from consumed_samples
- Checkpoint meta.json stores consumed_samples, drops iteration
- CLI --start_batch renamed to --start_samples (per-rank samples)
- Checkpoint dir naming: epoch_X_step_Y instead of epoch_X_iter_Y
- Metric log entries use step and consumed_samples fields
- Backward compat removed (old iteration checkpoints unsupported)
2026-06-30 18:42:42 +08:00
ViperEkura 44579ea6dc refactor : metric 日志改为以 optimizer step 为单位,默认每步记录
- log_interval 默认 100 -> 1,语义从 batch iteration 改为 optimizer step
- step 指标从 on_batch_end 移到 on_optimizer_step,不受梯度累积影响
- JSONL 条目新增 step 字段,保留 iter
- flush 落盘仍在 on_batch_end
2026-06-30 15:12:31 +08:00
ViperEkura 0f1fcb079f refactor : grad_norm 指标简化,clip_grad_norm 移至 executor
- metrics 默认加入 grad_norm,移除 grad_std/max/min/mean/nan_num
- grad_norm 默认返回总 L2 范数,per_param=True 返回各参数范数
- clip_grad_norm 从 callback 移至 BaseExecutor/FSDPExecutor
- FSDPExecutor 覆盖为 model.clip_grad_norm_() 保证分布式正确
- ctx_get_grad_norm 改为读取 context.grad_norm
2026-06-30 14:59:43 +08:00
ViperEkura 84d4769163 feat: SVD 有效秩/权重统计分析脚本 2026-06-29 21:39:22 +08:00
ViperEkura bf09a35c95 feat: optimizer 参数分组,bias/norm 不做 weight decay 2026-06-27 16:30:34 +08:00
ViperEkura 6715461a36 chore : 升级 torch 2.11.0+cu128,移除自定义 Muon,修复 gloo device_id
- torch 2.7.1-cu126 升级至 2.11.0-cu128,numpy 2.3.2 升级至 2.4.4
- 移除 astrai/trainer/optim.py,改用 torch.optim.Muon
- parallel setup: gloo 后端不再传递 device_id,单卡多进程不再报错
2026-06-27 16:10:37 +08:00
ViperEkura b4587c5d08 refactor : metric_logger 改用事件类型 (type=step/validation/epoch)
- 每种事件独立 schema,不再混入 null 字段
- 回调顺序 validation 移到 metric_logger 之前,确保 on_optimizer_step 先跑
- 用内部 _last_val_loss 代替 TrainContext.last_val_iter 判断新验证
- 修复 factory.py 未使用导入、evaluate_ifeval.py 多余 f 前缀
2026-06-25 17:18:20 +08:00
ViperEkura 88ec63121d feat : GPT-2 residual scaling weight init
- Linear: normal(0, init_std) replaces kaiming_uniform_(a=sqrt(5))
- o_proj / mlp.down: init_std = 0.02 / sqrt(2 * n_layers)
- MoE: expert down scaled by 1/sqrt(1/n_shared + 1/K)
- Embedding: normal(0, 0.02), unchanged
2026-06-25 15:08:31 +08:00
ViperEkura 01d2da2893 feat : 训练支持 --schedule_type 及对应调度器参数
- --schedule_type 可选 cosine/sgdr/wsd,默认 cosine
- --min_rate 统一控制最小 LR 比率
- --cycle_length / --t_mult 用于 sgdr
- --stable_steps / --decay_steps 用于 wsd,自动计算默认值
2026-06-22 10:35:56 +08:00
ViperEkura 25d4ea3f91 refactor : 压缩测试代码,消除重复
- fixture 替代重复实例化和 tokenizer 落盘
- parametrize 合并同构测试
- helper 消除 save_h5 + DatasetFactory.load 样板
- 净减 272 行
2026-06-19 14:54:39 +08:00
ViperEkura 39985840c7 refactor : neftune_alpha 在 Embedding 构造时传入,由模型配置链路负责
- BaseModelConfig 添加 neftune_alpha 字段 (默认 0.0)
- Embedding.__init__ 接受 neftune_alpha 参数,不再外部 set
- AutoRegressiveLM / EmbeddingEncoder 从 config 传入 neftune_alpha
- train.py 将 CLI 参数注入 config 后再创建模型
- TrainContextBuilder 移除 neftune 设置(不再是其职责)
2026-06-19 14:23:27 +08:00
ViperEkura b1adc40cfb refactor : 将 config 对象直接传给 DecoderBlock,替代 16 个独立参数
- DecoderBlock.__init__ 改为 (config, layer_id),内部用 asdict
  展开字段给 AttnFactory/FFNFactory,factory 按 __init__ 签名自动过滤
- EncoderConfig 补充 attn_type 和 ffn_type 字段
- 314 个测试全部通过
2026-06-19 14:15:33 +08:00
ViperEkura 7348bac6ab fix: 规范 generate.py 命令行接口
- generate.py 清理描述文字,help 统一标注默认值
- max_tokens 默认改为 None,回退 model config max_len
- evaluate_ppl.py 同步清理描述文字
- params.md 同步 max_tokens 默认值
2026-06-19 14:03:02 +08:00
ViperEkura 8ab7564d02 docs: 重构 README 结构,全文档添加目录导航
- README 新增 Getting Started 端到端流程,整合快速开始与演示,去重精简
- 中文 README 同步英文版结构,预处理配置改用 seq 策略
- inference.md 补充 SSE 流式格式、错误响应、/stats 端点文档
- params.md 扩展为 CLI 参考,覆盖 server/generate/preprocess 参数表
- dataflow.md 拆分 tokenization/format detection/backend 子节,新增流程图
- architecture/training/inference/preprocessing 均添加目录导航
- 移除 README CI badge
2026-06-19 13:53:22 +08:00
ViperEkura d096b6e29e docs: 修复文档中过时的字段、签名和缺失的类
- BaseConfig 的 from_json/to_json → from_file/to_file
- InputConfig/ProcessingConfig/OutputConfig 字段对齐源码
- 移除不存在的 Registry 类,register() 去 category/priority
- SchedulerFactory.create 参数顺序修正
- 架构图/训练/参数文档补全 WSDScheduler
- CONTRIBUTING.md 克隆地址占位符修正
- params.md label_smoothing 默认值修正,补全 neftune_alpha
- app 类更正为 get_app 函数
2026-06-18 18:49:46 +08:00
ViperEkura d88a41f8f1 fix: 修复预处理流水线 4 个致命问题
- pipeline: 单条数据异常不再崩溃整条流水线, 改 log warning 后跳过
- pipeline: _align_bucket 统一用 len(ids) 填充, 修复多输出模式下长度错配
- writer: BinWriter/H5Writer 写入失败自动清理残留文件并记录详细错误
- packing: BFDPacking 真正将序列打包进 bin 而非仅重排, 减少碎片
2026-06-18 17:38:01 +08:00
ViperEkura 376e9eba80 feat: IFEval 使用 chat template 格式化 prompt,添加 model.eval()
- generate_one 用 tokenizer.apply_chat_template 包 user 消息
- 新增 model.eval() 关闭 dropout,确保确定性输出
2026-06-18 16:45:16 +08:00
ViperEkura a62c2e11a2 feat: IFD 默认使用 chat template,支持裸文本模式
- 新增 _compute_ifd_with_template,用 tokenizer chat template 格式化后计算 IFD
- 默认开启 chat template,可通过 --no_chat_template 切换回裸拼接
- chat template 缺失时给出 RuntimeError 提示
2026-06-18 16:35:05 +08:00
ViperEkura a4e5a8c81c feat: 新增 WSD 学习率调度器
- 支持 Warmup-Stable-Decay 三段式调度
- stable 阶段保持最高 lr,decay 阶段 sqrt 衰减
- 适用于持续预训练、SFT、RLHF 场景
2026-06-18 15:55:15 +08:00
ViperEkura 3e234c46f6 fix: 使用 threading.Event 替代裸 bool,补全公共 API
- scheduler 停止信号改用 threading.Event,跨解释器安全
- 移除 _fatal_error 和 check_health,异常仅用 logger.error 记录
- 补全 astrai/__init__.py,暴露所有主要模块
2026-06-18 15:38:35 +08:00
ViperEkura 7a04b1f8ce docs: replace shields.io endpoint badges with github/ direct badges
- Switch stars/forks/release to github/ endpoints to avoid pool exhaustion
- Add CI workflow badge for tests.yml
- Delete update-badges.yml (no longer needed)
- Remove remote gh-pages branch
2026-06-18 15:09:51 +08:00
98 changed files with 6548 additions and 2004 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build-pure:
name: Build pure-Python wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build wheel (no CUDA)
run: |
pip wheel . --no-deps -w dist/
- uses: actions/upload-artifact@v4
with:
name: pure-wheel
path: dist/*.whl
build-cuda-linux:
name: Build CUDA wheel (Linux)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install torch (CUDA 12.8)
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu128
- name: Setup CUDA
uses: Jimver/cuda-toolkit@v0.2.35
with:
cuda: "12.8.0"
- name: Build wheel (with CUDA kernels)
run: |
CSRC_KERNELS=true pip wheel . --no-deps --no-build-isolation -w dist/
- uses: actions/upload-artifact@v4
with:
name: cuda-wheel-linux
path: dist/*.whl
release:
name: Attach wheels to release
needs: [build-pure, build-cuda-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
pattern: "*-wheel"
merge-multiple: true
- name: Create release & upload assets
uses: softprops/action-gh-release@v2
with:
files: ./*.whl
tag_name: ${{ github.ref_name }}
generate_release_notes: true
-44
View File
@@ -1,44 +0,0 @@
name: Update Badges
on:
push:
branches: [main]
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fetch repo stats
id: api
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p badges
REPO=$(gh repo view --json stargazerCount,forkCount,latestRelease --jq '.')
STARS=$(echo "$REPO" | jq -r '.stargazerCount')
FORKS=$(echo "$REPO" | jq -r '.forkCount')
RELEASE=$(echo "$REPO" | jq -r '.latestRelease.tagName // "N/A"')
echo '{"schemaVersion":1,"label":"release","message":"'"$RELEASE"'","color":"76bad9"}' > badges/release.json
echo '{"schemaVersion":1,"label":"stars","message":"'"$STARS"'","color":"76bad9"}' > badges/stars.json
echo '{"schemaVersion":1,"label":"forks","message":"'"$FORKS"'","color":"76bad9"}' > badges/forks.json
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: badges
destination_dir: badges
commit_message: "Sync badges"
user_name: "github-actions[bot]"
user_email: "github-actions[bot]@users.noreply.github.com"
+15 -2
View File
@@ -5,8 +5,16 @@
!*/
# Allow specific file types and root files
!*.py
!*.sh
!astrai/**/*.py
!scripts/**/*.py
!tests/**/*.py
!csrc/**/*.py
!csrc/**/*.cu
!csrc/**/*.h
!csrc/**/*.cuh
!scripts/**/*.sh
# Allow GitHub files
!/.github/**
@@ -21,3 +29,8 @@
!/LICENSE
!/pyproject.toml
!/README.md
# Allow extension modules (only source .py)
!/astrai/extension/**/*.py
# Allow build files
!/setup.py
+1 -1
View File
@@ -5,7 +5,7 @@ Thank you for your interest in contributing! This document provides step-by-step
## Quick Start
```bash
git clone https://github.com/your-username/AstrAI.git
git clone https://github.com/ViperEkura/AstrAI.git
cd AstrAI
pip install -e ".[dev]" # install with dev dependencies (pytest, ruff)
```
+1 -1
View File
@@ -23,7 +23,7 @@ COPY astrai/ ./astrai/
COPY pyproject.toml .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir . \
--extra-index-url https://download.pytorch.org/whl/cu126
--extra-index-url https://download.pytorch.org/whl/cu128
# Production stage
FROM ubuntu:24.04 AS production
+79 -80
View File
@@ -9,9 +9,9 @@
<div align="center">
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/release.json" alt="release">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/stars.json" alt="stars">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/forks.json" alt="forks">
<img src="https://img.shields.io/github/v/tag/ViperEkura/AstrAI?label=Release&color=76bad9" alt="release">
<img src="https://img.shields.io/github/stars/ViperEkura/AstrAI?style=flat&label=Stars&color=76bad9" alt="stars">
<img src="https://img.shields.io/github/forks/ViperEkura/AstrAI?style=flat&label=Forks&color=76bad9" alt="forks">
</div>
<br>
@@ -28,7 +28,8 @@
## 📖 Table of Contents
- [Features](#features)
- [Quick Start](#quick-start)
- [Getting Started](#getting-started)
- [Demo](#demo)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [Community](#community)
@@ -49,33 +50,44 @@
- 🤗 **HuggingFace-Style API**: AutoModel/AutoTokenizer APIs inspired by HuggingFace for easy model and tokenizer loading.
- 🔌 **Dual API Compatibility**: Supports both OpenAI and Anthropic chat completion APIs out of the box.
### Quick Start
### Getting Started
#### Installation
End-to-end walkthrough in 5 steps:
**1. Install**
```bash
git clone https://github.com/ViperEkura/AstrAI.git
cd AstrAI
pip install -e .
pip install -e . # pure PyTorch (no CUDA kernels)
# CSRC_KERNELS=true pip install -e . --no-build-isolation # optional: fused CUDA kernels
# pip install -e ".[dev]" # dev dependencies (pytest, ruff)
```
For development dependencies:
**2. Download model**
```bash
pip install -e ".[dev]"
python scripts/demo/download.py # downloads 1B checkpoint to params/
```
#### Download Pre-trained Model
**3. Preprocess data**
Download pre-trained model weights (1B bilingual checkpoint) to `params/`:
Create `pretrain.json` (preprocessing config for `seq` strategy):
```json
{
"version": 1,
"input": {"sections": [{"field": "text", "action": "train"}]},
"preprocessing": {"max_seq_len": 2048},
"output": {"storage_format": "bin"}
}
```
```bash
python scripts/demo/download.py
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c pretrain.json
```
Or download manually from [HuggingFace](https://huggingface.co/ViperEk/KHAOSZ) into `params/`.
#### Train a Model
**4. Train**
```bash
export CUDA_VISIBLE_DEVICES=0,1,2,3
@@ -91,9 +103,7 @@ nohup python scripts/tools/train.py \
--warmup_ratio=0.05 \
--max_lr=1e-4 \
--max_grad_norm=1.0 \
--adamw_beta1=0.9 \
--adamw_beta2=0.95 \
--adamw_weight_decay=0.01 \
--weight_decay=0.1 \
--window_size=2048 \
--ckpt_interval=10000 \
--ckpt_dir=./checkpoint \
@@ -102,15 +112,54 @@ nohup python scripts/tools/train.py \
> out.log 2> err.log &
```
Full reference at [Parameter Guide](assets/docs/params.md).
**5. Serve & query**
#### Generate Text
```bash
# Terminal 1: start server
python scripts/tools/server.py --param_path ./params --device cuda
# Terminal 2: query
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
```
### Demo
Check out the demos in the `scripts/demo/` folder:
```bash
# Download model weights (required before running demos)
python scripts/demo/download.py # model → params/
# Interactive streaming chat (multi-turn, maintains history)
python scripts/demo/stream_chat.py
# Type your message after >>, type !exit to quit
# Batch generation (5 hardcoded prompts, non-streaming)
python scripts/demo/generate_batch.py
# Single-prompt autoregressive streaming
python scripts/demo/generate_ar.py
```
All generation demos use `temperature=0.8`, `top_p=0.95`, `top_k=50`, `max_tokens=2048` by default and require `params/` to contain model weights (run `download.py` first).
Watch a video walkthrough on [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6).
---
See [Documentation](#documentation) for full references beyond the examples above.
#### Text Generation
Batch generation from a JSONL file:
```bash
python scripts/tools/generate.py \
--param_path /path/to/model \
--input_json_file /path/to/input.jsonl \
--output_json_file /path/to/output.jsonl
--param_path ./params \
--input_json_file input.jsonl \
--output_json_file output.jsonl
```
#### Docker
@@ -124,9 +173,6 @@ docker build -t astrai:latest .
# Run with GPU support
docker run --gpus all -it astrai:latest
# Run with specific GPUs
docker run --gpus '"device=0,1"' -it astrai:latest
# Run inference server
docker run --gpus all -p 8000:8000 astrai:latest \
python -m scripts.tools.server --port 8000 --device cuda
@@ -143,84 +189,37 @@ docker compose --profile cpu up -d
> **Note**: `--gpus all` is required for CUDA support. Without it, `torch.cuda.is_available()` will return `False`.
#### Start HTTP Server
#### HTTP API Examples
Start the inference server with OpenAI and Anthropic-compatible HTTP API:
Additional request examples beyond the [Getting Started](#getting-started) flow:
```bash
python -m scripts.tools.server --port 8000 --device cuda
```
Make requests:
```bash
# OpenAI-compatible
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 512
}'
# OpenAI-compatible streaming
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "Tell a story"}],
"stream": true,
"max_tokens": 500
}'
-d '{"messages":[{"role":"user","content":"Tell a story"}],"stream":true,"max_tokens":500}'
# Anthropic-compatible
curl -X POST http://localhost:8000/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "astrai",
"system": "You are a helpful assistant.",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 512
}'
-d '{"model":"astrai","system":"You are a helpful assistant.","messages":[{"role":"user","content":"Hello"}],"max_tokens":512}'
# Anthropic-compatible streaming with stop sequences
curl -X POST http://localhost:8000/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "astrai",
"messages": [{"role": "user", "content": "Write a story"}],
"max_tokens": 500,
"stream": true,
"stop_sequences": ["The end"]
}'
-d '{"model":"astrai","messages":[{"role":"user","content":"Write a story"}],"max_tokens":500,"stream":true,"stop_sequences":["The end"]}'
# Health check
curl http://localhost:8000/health
```
#### Demo
Check out the demos in the `scripts/demo/` folder:
```bash
# Download model weights (required before running demos)
python scripts/demo/download.py
# Interactive streaming chat
python scripts/demo/stream_chat.py
# Batch generation
python scripts/demo/generate_batch.py
# Autoregressive generation
python scripts/demo/generate_ar.py
```
Watch a video walkthrough on [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6).
See [Inference Guide](assets/docs/inference.md) for SSE streaming format, error codes, and stats endpoint.
### Documentation
| Document | Description |
|----------|-------------|
| [Parameter Guide](./assets/docs/params.md) | Training & inference parameters |
| [CLI Reference](./assets/docs/params.md) | Parameters for all CLI tools (train, server, generate, preprocess) |
| [Architecture](./assets/docs/architecture.md) | System architecture, class diagram & design patterns |
| [Training](./assets/docs/training.md) | Training loop, strategies & formulas |
| [Inference](./assets/docs/inference.md) | KVCache, continuous batching, sampling & HTTP API |
+78 -79
View File
@@ -15,9 +15,9 @@
<div align="center">
<img src="https://img.shields.io/badge/python-3.12+-blue.svg" alt="python">
<img src="https://img.shields.io/badge/license-GPL--3.0-blue.svg" alt="license">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/release.json" alt="release">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/stars.json" alt="stars">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/ViperEkura/AstrAI/gh-pages/badges/forks.json" alt="forks">
<img src="https://img.shields.io/github/v/tag/ViperEkura/AstrAI?label=Release&color=76bad9" alt="release">
<img src="https://img.shields.io/github/stars/ViperEkura/AstrAI?style=flat&label=Stars&color=76bad9" alt="stars">
<img src="https://img.shields.io/github/forks/ViperEkura/AstrAI?style=flat&label=Forks&color=76bad9" alt="forks">
</div>
<br>
@@ -34,7 +34,8 @@
## 📖 目录
- [特性](#特性)
- [快速开始](#快速开始)
- [快速上手](#快速上手)
- [演示](#演示)
- [文档](#文档)
- [贡献](#贡献)
- [社区](#社区)
@@ -55,33 +56,44 @@
- 🤗 **HuggingFace 风格 API**: 类 HuggingFace 的 AutoModel/AutoTokenizer 接口,方便加载模型和分词器。
- 🔌 **双 API 兼容**: 同时支持 OpenAI 和 Anthropic 聊天补全 API,开箱即用。
### 快速开始
### 快速上手
#### 安装
端到端演示,只需 5 步:
**1. 安装**
```bash
git clone https://github.com/ViperEkura/AstrAI.git
cd AstrAI
pip install -e .
pip install -e . # 纯 PyTorch(不含 CUDA 内核)
# CSRC_KERNELS=true pip install -e . --no-build-isolation # 可选:融合 CUDA 内核加速
# pip install -e ".[dev]" # 可选:开发依赖(pytest, ruff
```
安装开发依赖:
**2. 下载模型**
```bash
pip install -e ".[dev]"
python scripts/demo/download.py # 下载 1B 检查点到 params/
```
#### 下载预训练模型
**3. 预处理数据**
下载预训练模型权重(1B 双语检查点)到 `params/` 目录
创建 `pretrain.json``seq` 策略的预处理配置)
```json
{
"version": 1,
"input": {"sections": [{"field": "text", "action": "train"}]},
"preprocessing": {"max_seq_len": 2048},
"output": {"storage_format": "bin"}
}
```
```bash
python scripts/demo/download.py
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c pretrain.json
```
或从 [HuggingFace](https://huggingface.co/ViperEk/KHAOSZ) 手动下载放入 `params/`
#### 训练模型
**4. 训练**
```bash
export CUDA_VISIBLE_DEVICES=0,1,2,3
@@ -97,9 +109,7 @@ nohup python scripts/tools/train.py \
--warmup_ratio=0.05 \
--max_lr=1e-4 \
--max_grad_norm=1.0 \
--adamw_beta1=0.9 \
--adamw_beta2=0.95 \
--adamw_weight_decay=0.01 \
--weight_decay=0.1 \
--window_size=2048 \
--ckpt_interval=10000 \
--ckpt_dir=./checkpoint \
@@ -108,15 +118,54 @@ nohup python scripts/tools/train.py \
> out.log 2> err.log &
```
完整参数列表见[参数说明](./params.md)。
**5. 启动服务并调用**
```bash
# 终端 1:启动服务
python scripts/tools/server.py --param_path ./params --device cuda
# 终端 2:发起请求
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"你好"}],"max_tokens":512}'
```
### 演示
查看 `scripts/demo/` 文件夹中的演示:
```bash
# 下载模型权重(运行演示前必需)
python scripts/demo/download.py # model → params/
# 交互式流式聊天(多轮对话,保持历史记录)
python scripts/demo/stream_chat.py
# 在 >> 后输入消息,输入 !exit 退出
# 批量生成(5 条硬编码提示词,非流式)
python scripts/demo/generate_batch.py
# 单条提示词自回归流式生成
python scripts/demo/generate_ar.py
```
所有生成演示默认使用 `temperature=0.8``top_p=0.95``top_k=50``max_tokens=2048`,需要 `params/` 目录包含模型权重(请先运行 `download.py`)。
观看 [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6) 上的视频演示。
---
更多选项请参考[文档](#文档)。
#### 文本生成
从 JSONL 文件批量生成:
```bash
python scripts/tools/generate.py \
--param_path /path/to/model \
--input_json_file /path/to/input.jsonl \
--output_json_file /path/to/output.jsonl
--param_path ./params \
--input_json_file input.jsonl \
--output_json_file output.jsonl
```
#### Docker
@@ -130,9 +179,6 @@ docker build -t astrai:latest .
# 启用 GPU 运行
docker run --gpus all -it astrai:latest
# 指定特定 GPU
docker run --gpus '"device=0,1"' -it astrai:latest
# 运行推理服务
docker run --gpus all -p 8000:8000 astrai:latest \
python -m scripts.tools.server --port 8000 --device cuda
@@ -149,84 +195,37 @@ docker compose --profile cpu up -d
> **注意**: 必须使用 `--gpus all` 才能启用 CUDA 支持,否则 `torch.cuda.is_available()` 将返回 `False`。
#### 启动 HTTP 服务
#### HTTP API 示例
启动推理服务器,支持 OpenAI 和 Anthropic 兼容的 HTTP API
除[快速上手](#快速上手)流程外,更多请求示例
```bash
python -m scripts.tools.server --port 8000 --device cuda
```
发起请求:
```bash
# OpenAI 兼容
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 512
}'
# OpenAI 兼容流式
curl -X POST http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "讲个故事"}],
"stream": true,
"max_tokens": 500
}'
-d '{"messages":[{"role":"user","content":"讲个故事"}],"stream":true,"max_tokens":500}'
# Anthropic 兼容
curl -X POST http://localhost:8000/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "astrai",
"system": "你是一个乐于助人的助手。",
"messages": [{"role": "user", "content": "你好"}],
"max_tokens": 512
}'
-d '{"model":"astrai","system":"你是一个乐于助人的助手。","messages":[{"role":"user","content":"你好"}],"max_tokens":512}'
# Anthropic 兼容流式并设置停止序列
curl -X POST http://localhost:8000/v1/messages \
-H "Content-Type: application/json" \
-d '{
"model": "astrai",
"messages": [{"role": "user", "content": "写个故事"}],
"max_tokens": 500,
"stream": true,
"stop_sequences": ["结束"]
}'
-d '{"model":"astrai","messages":[{"role":"user","content":"写个故事"}],"max_tokens":500,"stream":true,"stop_sequences":["结束"]}'
# 健康检查
curl http://localhost:8000/health
```
#### 演示
查看 `scripts/demo/` 文件夹中的演示:
```bash
# 下载模型权重(运行演示前必需)
python scripts/demo/download.py
# 交互式流式聊天
python scripts/demo/stream_chat.py
# 批量生成
python scripts/demo/generate_batch.py
# 自回归生成
python scripts/demo/generate_ar.py
```
观看 [bilibili](https://www.bilibili.com/video/BV1fuLB6yEj6) 上的视频演示。
SSE 流式格式、错误码和统计端点详见[推理文档](./inference.md)。
### 文档
| 文档 | 说明 |
|------|------|
| [参数说明](./params.md) | 训练与推理参数配置 |
| [CLI 参考](./params.md) | 所有 CLI 工具参数(训练、服务、生成、预处理) |
| [架构文档](./architecture.md) | 系统架构、类图与设计模式 |
| [训练文档](./training.md) | 训练循环、策略与公式 |
| [推理文档](./inference.md) | KVCache、连续批处理、采样与 HTTP API |
+134 -88
View File
@@ -1,5 +1,12 @@
# AstrAI Architecture
## Contents
- [Class Diagram](#class-diagram) — Full Mermaid class diagram across 10+ namespaces
- [Module Overview](#module-overview) — Component inventory per module
- [Design Patterns](#design-patterns) — 13 documented patterns with classes
- [Core Relationships](#core-relationships) — 11 key inter-component relationships
## Class Diagram
```mermaid
@@ -8,12 +15,13 @@ classDiagram
class BaseConfig {
+to_dict() Dict
+from_dict(d) Self
+from_json(path) Self
+to_json(path)
+from_file(path) Self
+to_file(path)
}
class BaseModelConfig {
+Optional[str] model_type
+float neftune_alpha
+from_file(config_path) Self
+to_file(config_path)
}
@@ -51,41 +59,43 @@ classDiagram
+Optional[int] dim_ffn
+Optional[int] max_len
+Optional[float] rope_theta
+str attn_type
+Optional[int] n_heads
+Optional[int] n_kv_heads
+Optional[bool] use_qk_norm
+Optional[bool] use_gated_attention
+str ffn_type
+Optional[dict] rope_scaling
+Optional[str] pooling_type
+Optional[bool] normalize_embeddings
}
class ConfigFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+load(raw) BaseConfig
}
class InputConfig {
+str type
+str messages_key
+str prompt_key
+str response_key
+str text_key
+Optional[List[Dict]] sections
+Optional[Dict[str, Dict]] sources
}
class ProcessingConfig {
+int max_seq_len
+int min_chars
+int max_chars
+bool deduplicate
+Optional[int] max_items
+str packing_strategy
+int max_packed_len
+str truncation_mode
}
class OutputConfig {
+Optional[str] domain_key
+str storage_format
+int max_tokens_per_shard
+Dict[str, str] dtype
+str position_ids_mode
}
class PipelineConfig {
@@ -110,11 +120,10 @@ classDiagram
+float max_grad_norm
+list gradient_checkpointing_modules
+int start_epoch
+int start_batch
+int start_samples
+str ckpt_dir
+int ckpt_interval
+str log_dir
+int log_interval
+List[str] metrics
+Optional[LoRAConfig] lora
+int random_seed
@@ -128,7 +137,9 @@ classDiagram
+str start_method
+str device_type
+Optional[Dataset] val_dataset
+Optional[float] val_split
+int val_step
+float neftune_alpha
+str parallel_mode
+dict executor_kwargs
+dict extra_kwargs
@@ -190,13 +201,13 @@ classDiagram
}
class StoreFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(storage_type) Store
}
class DatasetFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(train_type, window_size, stride) BaseDataset
+load(train_type, load_path, window_size, stride, storage_type) BaseDataset
@@ -207,19 +218,20 @@ classDiagram
class Checkpoint {
+dict state_dict
+int epoch
+int iteration
+int consumed_samples
+dict extra
+dict meta
+dict config
+save(save_dir)
+load(save_dir, broadcast) Checkpoint
+load_any(save_dir, broadcast) Optional[Checkpoint]
}
}
namespace model {
class AutoModel {
+BaseModelConfig config
+Registry _registry
+Dict _entries
+register(name) decorator
+get_component_class(name) Type
+from_pretrained(path, disable_random_init, strict) nn.Module
@@ -342,7 +354,9 @@ classDiagram
class Embedding {
+Parameter weight
+float neftune_noise_alpha
+forward(x) Tensor
+set_neftune_alpha(alpha)
}
}
@@ -395,24 +409,19 @@ classDiagram
}
namespace factory {
class Registry {
+Dict _entries
+register(name, component_cls, category, priority)
+get(name) Type
+list_names() List[str]
}
class BaseFactory {
+Registry _registry
+register(name, category, priority) decorator
+Dict _entries
+register(name) decorator
+create(name, *args, **kwargs) T
+get_component_class(name) Type
+list_registered() list
+is_registered(name) bool
}
class MaskBuilderFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(input_type, config, tokenizer) BaseMaskBuilder
+create(name, *args, **kwargs) BaseMaskBuilder
}
}
@@ -435,13 +444,15 @@ classDiagram
+dict model_config
+BaseExecutor executor
+int epoch
+int iteration
+int consumed_samples
+float loss
+float grad_norm
+DataLoader val_dataloader
+float val_loss
+int world_size
+int rank
+dict kwargs
+optimizer_step() int
}
class TrainContextBuilder {
@@ -461,7 +472,7 @@ classDiagram
}
class StrategyFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(train_type, model, device, **kwargs) BaseStrategy
}
@@ -502,9 +513,9 @@ classDiagram
}
class SchedulerFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(optimizer, schedule_type, **kwargs) BaseScheduler
+create(name, *args, **kwargs) BaseScheduler
}
class CosineScheduler {
@@ -521,6 +532,13 @@ classDiagram
+int t_mult
}
class WSDScheduler {
+int warmup_steps
+int stable_steps
+int decay_steps
+float min_rate
}
class TrainCallback {
<<protocol>>
+on_train_begin(context)
@@ -539,7 +557,7 @@ classDiagram
}
class GradientCheckpointingCallback {
+tuple modules
+Optional[List[type]] modules
+on_train_begin(context)
+on_train_end(context)
}
@@ -553,51 +571,37 @@ classDiagram
+on_batch_end(context)
+on_train_end(context)
+on_error(context)
+save_extra(context) dict$
+save_extra(context) dict
}
class ProgressBarCallback {
+int num_epoch
+int log_interval
+IO file
+tqdm progress_bar
+on_epoch_begin(context)
+on_batch_end(context)
+on_optimizer_step(context)
+on_epoch_end(context)
}
class MetricLoggerCallback {
class MetricCallback {
+Path log_dir
+int save_interval
+int log_interval
+List[str] metrics
+on_batch_end(context)
+int val_step
+on_optimizer_step(context)
+on_epoch_end(context)
+on_train_end(context)
+on_error(context)
}
class ValidationCallback {
-_run_validation(context)
+on_optimizer_step(context)
}
class CallbackFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(name, **kwargs) TrainCallback
}
class Muon {
+float lr
+float momentum
+float weight_decay
+bool nesterov
+int ns_steps
+Optional[float] adamw_lr
+tuple adamw_betas
+float adamw_eps
+float adamw_wd
+step(closure) Optional[float]
}
}
namespace inference {
@@ -676,20 +680,44 @@ classDiagram
}
class KVCache {
-PagePool _pool
-Storage _storage
-TaskTable _table
+int page_size
<<abstract>>
+task_alloc(task_id, prompt_ids) bool
+task_free(task_id)
+task_extend(task_id, pos) bool
+task_cached(task_id) int
+task_record_hashes(task_id, prompt_ids, start_logical_page)
+make_table_tensor(task_ids, device) Tensor
+bind(page_table, total_len) KvcacheView
+bind_tasks(task_ids, total_len, device) CacheView
}
class KvcacheView {
class PageCache {
+int page_size
-PagePool _pool
-Storage _storage
-TaskTable _table
+task_alloc(task_id, prompt_ids) bool
+task_free(task_id)
+task_extend(task_id, pos) bool
+task_cached(task_id) int
+task_record_hashes(task_id, prompt_ids, start_logical_page)
+bind_tasks(task_ids, total_len, device) PageCacheView
}
class ContiguousCache {
+int max_seq_len
+Tensor k, v
+task_alloc(task_id, prompt_ids) bool
+task_free(task_id)
+task_extend(task_id, pos) bool
+bind_tasks(task_ids, total_len, device) ContiguousCacheView
}
class CacheView {
<<abstract>>
+write(layer_id, k, v)
+gather(layer_id) Tuple[Tensor, Tensor]
}
class PageCacheView {
-Storage _storage
+Tensor _page_table
+int _total_len
@@ -697,6 +725,14 @@ classDiagram
+gather(layer_id) Tuple[Tensor, Tensor]
}
class ContiguousCacheView {
-ContiguousCache _cache
+Tensor _batch_indices
+int _total_len
+write(layer_id, k, v)
+gather(layer_id) Tuple[Tensor, Tensor]
}
class TaskTable {
+set(task_id, page_table, cached)
+get(task_id) List[int]
@@ -719,7 +755,6 @@ classDiagram
+int output_tokens
+float arrival_time
+Optional[float] finish_time
+Optional[Callable] stream_callback
+int next_pos
+is_finished(stop_ids) bool
}
@@ -802,7 +837,9 @@ classDiagram
class ChatMessage {
+str role
+str content
+Optional[str] content
+Optional[List[Dict]] tool_calls
+Optional[str] tool_call_id
}
class ChatCompletionRequest {
@@ -819,6 +856,8 @@ classDiagram
+Optional[float] frequency_penalty
+Optional[Dict[int, float]] logit_bias
+Optional[str] user
+Optional[List[ToolDef]] tools
+Optional[Union[str, Dict]] tool_choice
}
class AnthropicMessage {
@@ -842,7 +881,7 @@ classDiagram
<<abstract>>
+prepare(request, engine) Tuple[str, GenContext, List[str]]
+format_stream_start(ctx) List[str]
+format_chunk(token) str
+format_chunk(token) List[str]
+format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict
}
@@ -850,7 +889,7 @@ classDiagram
class OpenAIResponseBuilder {
+prepare(request, engine) Tuple
+format_stream_start(ctx) List[str]
+format_chunk(token) str
+format_chunk(token) List[str]
+format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict
}
@@ -858,7 +897,7 @@ classDiagram
class AnthropicResponseBuilder {
+prepare(request, engine) Tuple
+format_stream_start(ctx) List[str]
+format_chunk(token) str
+format_chunk(token) List[str]
+format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict
}
@@ -891,9 +930,9 @@ classDiagram
+str yielded
}
class app {
<<singleton>>
+FastAPI app
class get_app {
<<module>>
+get_app() FastAPI
}
}
@@ -975,7 +1014,7 @@ classDiagram
}
class ExecutorFactory {
+Registry _registry
+Dict _entries
+register(name) decorator
+create(parallel_mode, **kwargs) BaseExecutor
}
@@ -1018,18 +1057,19 @@ classDiagram
BaseStrategy <|-- GRPOStrategy
BaseScheduler <|-- CosineScheduler
BaseScheduler <|-- SGDRScheduler
BaseScheduler <|-- WSDScheduler
TrainCallback <|-- GradientClippingCallback
TrainCallback <|-- GradientCheckpointingCallback
TrainCallback <|-- CheckpointCallback
TrainCallback <|-- ProgressBarCallback
TrainCallback <|-- MetricLoggerCallback
TrainCallback <|-- ValidationCallback
TrainCallback <|-- MetricCallback
BaseDataset <|-- SEQDataset
BaseDataset <|-- SFTDataset
BaseDataset <|-- DPODataset
BaseDataset <|-- GRPODataset
Store <|-- H5Store
Store <|-- MmapStore
Store <|-- JsonlStore
BaseSamplingStrategy <|-- TemperatureStrategy
BaseSamplingStrategy <|-- TopKStrategy
BaseSamplingStrategy <|-- TopPStrategy
@@ -1062,11 +1102,15 @@ classDiagram
ResponseBuilder <|-- OpenAIResponseBuilder
ResponseBuilder <|-- AnthropicResponseBuilder
BaseMaskBuilder <|-- SectionedMaskBuilder
KVCache <|-- PageCache
KVCache <|-- ContiguousCache
CacheView <|-- PageCacheView
CacheView <|-- ContiguousCacheView
%% --- Composition (strong ownership, part destroyed with whole) ---
KVCache *-- PagePool
KVCache *-- Storage
KVCache *-- TaskTable
PageCache *-- PagePool
PageCache *-- Storage
PageCache *-- TaskTable
InferenceEngine *-- InferenceScheduler
InferenceScheduler *-- KVCache
InferenceScheduler *-- Executor
@@ -1080,7 +1124,6 @@ classDiagram
DecoderBlock *-- RMSNorm
ChatCompletionRequest *-- ChatMessage
MessagesRequest *-- AnthropicMessage
BaseFactory *-- Registry
BaseExecutor *-- GradientState
AccumOptimizer o-- GradientState
AccumScheduler o-- GradientState
@@ -1095,7 +1138,8 @@ classDiagram
TrainContext o-- BaseScheduler
TrainContext o-- Checkpoint
TrainContext o-- BaseExecutor
KvcacheView o-- Storage
PageCacheView o-- Storage
ContiguousCacheView o-- ContiguousCache
SamplingPipeline o-- BaseSamplingStrategy
BaseDataset o-- Store
Pipeline o-- PipelineConfig
@@ -1117,6 +1161,7 @@ classDiagram
DecoderBlock ..> FFNFactory : uses
StoreFactory ..> H5Store : creates
StoreFactory ..> MmapStore : creates
StoreFactory ..> JsonlStore : creates
ConfigFactory ..> AutoRegressiveLMConfig : creates
ConfigFactory ..> EncoderConfig : creates
ExecutorFactory ..> NoneExecutor : creates
@@ -1130,7 +1175,8 @@ classDiagram
TrainContextBuilder ..> ResumableDistributedSampler : creates
Checkpoint ..> Checkpoint : serializes
CheckpointCallback ..> Checkpoint : creates
KVCache ..> KvcacheView : binds
PageCache ..> PageCacheView : binds
ContiguousCache ..> ContiguousCacheView : binds
InferenceEngine ..> GenerationRequest : uses
InferenceEngine ..> GenerateResult : creates
OpenAIResponseBuilder ..> ChatCompletionRequest : receives
@@ -1157,16 +1203,16 @@ classDiagram
| Module | Components | Description |
|--------|------------|-------------|
| **astrai.config** | BaseConfig, BaseModelConfig, AutoRegressiveLMConfig, EncoderConfig, ConfigFactory, TrainConfig, PipelineConfig, InputConfig, ProcessingConfig, OutputConfig | Configuration management (to_dict/from_dict, to_file/from_file, from_json/to_json) |
| **astrai.config** | BaseConfig, BaseModelConfig, AutoRegressiveLMConfig, EncoderConfig, ConfigFactory, TrainConfig, PipelineConfig, InputConfig, ProcessingConfig, OutputConfig | Configuration management (to_dict/from_dict, to_file/from_file) |
| **astrai.preprocessing** | BaseMaskBuilder, MaskBuilderFactory, SectionedMaskBuilder, Pipeline, filter_by_length, PackingStrategy, PackingStrategyFactory, PositionIdStrategy, PositionIdStrategyFactory, StoreWriter, StoreWriterFactory | Declarative JSON-driven data preprocessing |
| **astrai.dataset** | BaseDatasetGRPODataset, StoreMmapStore, StoreFactory, ResumableDistributedSampler, DatasetFactory | Dataset loading and management |
| **astrai.dataset** | BaseDatasetGRPODataset, StoreJsonlStore/MmapStore/H5Store, StoreFactory, ResumableDistributedSampler, DatasetFactory | Dataset loading and management |
| **astrai.serialization** | Checkpoint | Model serialization |
| **astrai.model** | AutoModel, AutoRegressiveLM, EmbeddingEncoder, DecoderBlock, GQA, MLA, MLP, DeepSeekMoE, AttnFactory, FFNFactory, RMSNorm, Linear, RotaryEmbedding, Embedding | Neural network model |
| **astrai.tokenize** | AutoTokenizer, ChatTemplate | Tokenizer and chat template |
| **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategyGRPOStrategy, StrategyFactory, BaseSchedulerSGDRScheduler, SchedulerFactory, TrainCallback(Protocol)ValidationCallback, CallbackFactory, Muon | Training workflow |
| **astrai.inference** | InferenceEngine, InferenceScheduler, Executor, KVCacheKvcacheView, AllocatorStorage, Task, TaskManager, TaskStatus, GenerationRequest, GenerateResult, BaseSamplingStrategySamplingPipeline, ProtocolHandler, ResponseBuilder, OpenAIResponseBuilder, AnthropicResponseBuilder, StopChecker, GenContext, ChatMessageMessagesRequest, app | Inference service |
| **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategyGRPOStrategy, StrategyFactory, BaseSchedulerWSDScheduler, SchedulerFactory, TrainCallback(Protocol)MetricCallback, CallbackFactory | Training workflow |
| **astrai.inference** | InferenceEngine, InferenceScheduler, Executor, KVCacheContiguousCache/PageCache, CacheViewContiguousCacheView/PageCacheView, AllocatorStorage, Task, TaskManager, TaskStatus, GenerationRequest, GenerateResult, BaseSamplingStrategySamplingPipeline, ProtocolHandler, ResponseBuilder, OpenAIResponseBuilder, AnthropicResponseBuilder, StopChecker, GenContext, ChatMessageMessagesRequest, app | Inference service |
| **astrai.parallel** | spawn_parallel_fn, setup_parallel, get_rank/get_world_size/get_current_device, only_on_rank, BaseExecutor, ExecutorFactory, NoneExecutor, DDPExecutor, FSDPExecutor, GradientState, AccumOptimizer, AccumScheduler, ParallelModel, RowParallelLinear, ColumnParallelLinear | Distributed parallel & gradient accumulation |
| **astrai.factory** | Registry, BaseFactory[T] | Component registration |
| **astrai.factory** | BaseFactory | Component registration |
| **astrai.protocols** | OptimizerProtocol, SchedulerProtocol | Structural subtyping for optimizer/scheduler wrappers |
## Design Patterns
@@ -1174,7 +1220,7 @@ classDiagram
| Pattern | Classes | Purpose |
|---------|---------|---------|
| **Factory** | `AttnFactory`, `FFNFactory`, `StrategyFactory`, `DatasetFactory`, `SchedulerFactory`, `CallbackFactory`, `StoreFactory`, `ConfigFactory`, `ExecutorFactory` | Decorator-based component creation |
| **Registry** | `BaseFactory`, `Registry` | Component registration with category/priority |
| **Registry** | `BaseFactory` | Component registration |
| **Strategy** | `SEQStrategy`, `SFTStrategy`, `DPOStrategy`, `GRPOStrategy` | Training strategy switching |
| **Strategy (Sampling)** | `TemperatureStrategy`, `TopKStrategy`, `TopPStrategy`, `SamplingPipeline` | Composable logit transformations |
| **Strategy (API)** | `ResponseBuilder`, `OpenAIResponseBuilder`, `AnthropicResponseBuilder` | HTTP API handler with format hooks |
@@ -1183,7 +1229,7 @@ classDiagram
| **Context** | `TrainContext` | Unified training state bag |
| **Object Pool** | `Allocator`, `PagePool` | Page-based KV cache with LRU eviction |
| **Executor** | `BaseExecutor`, `NoneExecutor`, `DDPExecutor`, `FSDPExecutor` | Gradient accumulation & model distribution |
| **Storage** | `Store`, `H5Store`, `MmapStore` | Format-agnostic data access with multi-segment support |
| **Storage** | `Store`, `H5Store`, `MmapStore`, `JsonlStore` | Format-agnostic data access with multi-segment support |
| **Producer-Consumer** | `InferenceScheduler`, `Task`, queues | Continuous batching |
| **AutoModel Registry** | `AutoModel`, `AutoRegressiveLM`, `EmbeddingEncoder` | Model-type dynamic loading |
@@ -1195,10 +1241,10 @@ classDiagram
4. **Executor Selection**: `ExecutorFactory.create(cfg.parallel_mode, grad_accum_steps=cfg.grad_accum_steps, **cfg.executor_kwargs)``NoneExecutor` / `DDPExecutor` / `FSDPExecutor`
5. **Inference Flow**: `InferenceEngine``InferenceScheduler``AutoRegressiveLM`, backed by `KVCache` + `SamplingPipeline`
6. **Distributed**: `spawn_parallel_fn` + `setup_parallel` for multi-process DDP
7. **Dataset Loading**: `DatasetFactory` creates datasets, `Store` (H5Store/MmapStore) loads data with explicit `_length` and multi-segment `_data`
7. **Dataset Loading**: `DatasetFactory` creates datasets, `Store` (H5Store/MmapStore/JsonlStore) loads data with explicit `_length` and multi-segment `_data`
8. **Checkpoint**: `Checkpoint` saves/loads safetensors + metadata (rank-0 only), extra state saved as `{key}.pt`
9. **Scheduler**: `SchedulerFactory` creates `CosineScheduler`/`SGDRScheduler`
9. **Scheduler**: `SchedulerFactory` creates `CosineScheduler`/`SGDRScheduler`/`WSDScheduler`
10. **AutoModel**: `from_pretrained()` loads `config.json` + `model.safetensors`, `_disable_random_init` replaces `nn.init.*` with no-ops
11. **Protocols**: `OptimizerProtocol` / `SchedulerProtocol` — structural subtyping for `AccumOptimizer` / `AccumScheduler` wrappers
> Document Update Time: 2026-05-30
> Document Update Time: 2026-07-09
+53 -5
View File
@@ -1,25 +1,73 @@
# Data Flow
This document describes the data pipeline: from raw text to model input tensors.
This document describes the data pipeline: from raw text to model input tensors. For creating preprocessing configs, see [Preprocessing Guide](preprocessing.md).
## Contents
- [Overview](#overview)
- [Data Preparation](#data-preparation) — tokenization, format detection, backends
- [Data Keys by Training Type](#data-keys-by-training-type)
- [Dataset Architecture](#dataset-architecture)
- [Sampler](#sampler)
- [DataLoader](#dataloader)
## Overview
```
Raw Text → AutoTokenizer → Token IDs → .h5/.bin → Store.load() → Store.fetch() → Dataset → Sampler → DataLoader → Training/Inference
JSONL Lines → Pipeline (mask builder) → Tokenized Tensors
.h5 or .bin storage
Store.load()
Store.fetch(begin, end, keys)
BaseDataset.__getitem__(idx)
Sampler → DataLoader → Training / Inference
```
## Data Preparation
Raw text is tokenized via `AutoTokenizer.encode()` and saved as HDF5 (`.h5`) or binary (`.bin` + `meta.json`) files with keyed tensor groups.
### Tokenization
The `Pipeline` reads JSONL lines, applies the mask builder (see [Preprocessing](preprocessing.md)), and produces flat token sequences:
```python
# Per JSONL line: messages → chat template → token IDs + loss mask
tokens = tokenizer.encode(rendered_text) # List[int]
loss_mask = [0, 0, 0, 1, 1, 1, 1, 1, 1] # 0=masked, 1=train
# Stored as flat tensors, packed with other lines by packing strategy
```
The output `meta.json` records the storage format, key names, dtype, total token count, and tensor shapes for each shard.
### Format Detection
`detect_format(load_path)` inspects the path:
- If `load_path` is a file: checks suffix — `.h5`/`.hdf5``"h5"`, `.jsonl``"jsonl"`, unknown suffix raises `ValueError`
- If `load_path` is a directory: recursively globs for `*.h5`/`*.hdf5` files → `"h5"`, `*.bin` + `**/meta.json``"bin"`, or `*.jsonl` + `dataset_config.json``"jsonl"`
### Store Backends
Storage format is auto-detected by `detect_format()`; backends are dispatched via registry:
```
StoreFactory.create("h5") → H5Store
StoreFactory.create("bin") → MmapStore
StoreFactory.create("jsonl") → JsonlStore
```
H5 backend supports shared memory via `.share_memory_()`. Bin (mmap) uses OS page-cache sharing natively.
**H5Store**: Reads HDF5 files. Tensors are loaded into host memory and normalized into segmented storage.
**MmapStore**: Memory-maps `.bin` files. OS page cache sharing is native — no explicit `share_memory_()` needed. Uses `torch.from_numpy(np.memmap(...))`.
**JsonlStore**: On-the-fly tokenization of raw JSONL files at load time. Requires a `dataset_config.json` alongside the `.jsonl` files following the same `PipelineConfig` schema with an additional `tokenizer_path` field.
All backends normalise tensors into `Store._data[Dict[str, List[Tensor]]]` + `Store._cum[Dict[str, List[int]]]` (cumulative lengths for bisect-based indexing).
## Data Keys by Training Type
@@ -38,7 +86,7 @@ DatasetFactory.load(train_type, load_path, window_size, stride=None, storage_typ
→ detect_format(load_path)
→ StoreFactory.create(storage_type)
→ Store.load(load_path)
H5Store._normalize() / MmapStore._normalize()
→ _normalize(raw) # base Store, shared by both backends
→ Store._data[Dict[str, List[Tensor]]] + _cum[Dict[str, List[int]]]
→ BaseDataset.__getitem__(idx)
→ get_index(idx) → [begin, end)
@@ -61,4 +109,4 @@ DatasetFactory.load(train_type, load_path, window_size, stride=None, storage_typ
Standard PyTorch `DataLoader` with configurable `batch_size`, `num_workers`, `pin_memory`, `prefetch_factor`. Sampler produces indices; dataloader fetches tensor batches via `__getitem__`.
> Document Update Time: 2026-05-30
> Document Update Time: 2026-07-09
+110 -10
View File
@@ -1,5 +1,16 @@
# Inference
## Contents
- [KV Cache](#kv-cache)
- [KVCache System](#kvcache-system)
- [Continuous Batching](#continuous-batching)
- [Sampling](#sampling-strategy-pattern)
- [Protocol Handlers](#protocol-handlers-strategy-pattern)
- [Engine & GenerateResult](#engine--generateresult)
- [HTTP API](#http-api) — endpoints, SSE, errors, stats
- [Engine API](#engine-api)
## KV Cache
At decode time, only the last query token matters. All previous K/V are cached to avoid recomputation:
@@ -12,29 +23,40 @@ RoPE is applied **before** KV cache write, not after — otherwise position enco
## KVCache System
Six classes (plus two helpers) working together:
Seven classes working together, with two concrete cache implementations:
### ContiguousCache (default)
```
KVCache (facade)
ContiguousCache (simple contiguous per-slot cache)
├── ContiguousCacheView bundles k/v tensors + slot indices for attention layers
```
Created by default when no cache is passed to `InferenceScheduler`. Each task occupies a fixed slot of `[max_seq_len, n_kv_heads, head_dim]`. Simple and efficient for small-to-medium batch sizes.
### PageCache (paged with prefix sharing)
```
PageCache (paged KV cache with prefix sharing, alternative)
├── PagePool orchestrates page allocation + prefix matching
│ ├── Allocator bitmask-based page allocator + ref-count + LRU eviction (inside PagePool)
│ └── PrefixCache hash-based prefix matching (page_hash via polynomial hash) (inside PagePool)
│ ├── Allocator bitmask-based page allocator + ref-count + LRU
│ └── PrefixCache hash-based prefix matching (page_hash via polynomial hash)
├── TaskTable maps task_id → page_table + cached token count
├── Storage k_cache / v_cache tensors (n_layers × n_pages × page_size × n_kv_heads × head_dim)
└── KvcacheView bundles Storage + page_table + total_len for attention layers (returned by bind())
└── PageCacheView bundles Storage + page_table + total_len for attention layers
```
`KVCache.bind(page_table, total_len)` returns a `KvcacheView` used by attention layers via `write()` / `gather()`.
`isinstance(cache, KVCache)` checks dispatch to the correct view. Both implement the abstract `KVCache` interface used by `Executor` and `InferenceScheduler`.
## Continuous Batching
`InferenceScheduler` runs a daemon thread with a 4-phase loop:
```
1. Cleanup → Remove finished tasks, free KV pages
2. Refill → Pop from waiting_queue, task_alloc pages, activate
1. Cleanup → Remove finished tasks, free KV cache slots/pages
2. Refill → Pop from waiting_queue, task_alloc resources, activate
3. Prefill → Group by (prompt_len, start_pos), run full forward
4. Decode → Pick largest same-position group, single-token forward
4. Decode → Run single-token forward for each same-position group
```
## Sampling (Strategy Pattern)
@@ -133,6 +155,84 @@ Supports `stop_sequences` and streaming via `event: content_block_delta`.
| `max_tokens` | Optional[int] | None | Max generation length |
| `stream` | bool | False | Stream output |
### SSE Streaming Format
**OpenAI** (`/v1/chat/completions`, `stream=true`):
```
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":0,"model":"astrai",
"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}
data: [DONE]
```
**Anthropic** (`/v1/messages`, `stream=true`):
```
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","model":"astrai","role":"assistant",
"content":[],"usage":{"input_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{...}}
event: message_stop
data: {"type":"message_stop"}
```
### Error Responses
The server returns standard HTTP status codes. Pydantic validation errors (e.g. missing required fields)
are handled automatically by FastAPI with 422 status. The only application-level error is engine initialization:
| Status | Meaning |
|--------|---------|
| 200 | Success |
| 422 | Unprocessable entity (Pydantic validation) |
| 503 | Service unavailable (model not loaded, engine not ready) |
Error response body (503):
```json
{
"detail": "Engine not initialized"
}
```
### Stats Endpoint
```
GET /stats
```
Response:
```json
{
"total_tasks": 128,
"total_tokens": 10240,
"active_tasks": 3,
"waiting_queue": 2
}
```
## Engine API
```python
@@ -149,4 +249,4 @@ async for token in engine.generate_async("Hello", ...): # -> AsyncGenerator[s
print(token)
```
> Document Update Time: 2026-05-30
> Document Update Time: 2026-07-09
+93 -13
View File
@@ -1,4 +1,11 @@
# Parameter Documentation
# CLI Parameter Reference
## Contents
- [Training Parameters](#training-parameters)
- [Inference Server](#inference-server-serverpy)
- [Generate](#generate-generatepy)
- [Preprocess](#preprocess-preprocesspy)
## Training Parameters
@@ -21,13 +28,17 @@
| `--max_lr` | Maximum learning rate (cosine decay after warmup) | 3e-4 |
| `--max_grad_norm` | Maximum gradient norm for clipping | 1.0 |
### Optimizer (AdamW)
### Optimizer (MuonMix)
Combined optimizer: matrix parameters via **Muon**, non-matrix via **AdamW** (`fused=True`).
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--adamw_beta1` | AdamW beta1 | 0.9 |
| `--adamw_beta2` | AdamW beta2 | 0.95 |
| `--adamw_weight_decay` | AdamW weight decay | 0.01 |
| `--weight_decay` | Weight decay (applied to Muon matrix params; non-matrix use 0) | 0.1 |
| `--muon_momentum` | Muon momentum factor | 0.95 |
| `--muon_nesterov` | Enable Nesterov momentum for Muon | True |
| `--muon_ns_steps` | Newton-Schulz iteration steps for Muon | 5 |
| `--muon_adjust_lr` | Muon LR adjustment strategy (`original`, `match_rms_adamw`) | `match_rms_adamw` |
### Data Loading
@@ -46,7 +57,7 @@
| `--ckpt_interval` | Iterations between checkpoints | 5000 |
| `--ckpt_dir` | Checkpoint save directory | checkpoint |
| `--start_epoch` | Resume from epoch (0 = from scratch) | 0 |
| `--start_batch` | Resume from batch iteration | 0 |
| `--start_samples` | Resume from sample count per rank | 0 |
### Validation
@@ -60,8 +71,7 @@
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--log_dir` | Directory for metric logs | checkpoint/logs |
| `--log_interval` | Number of batch iterations between metric logs | 100 |
| `--metrics` | Metrics to log (e.g. --metrics loss lr val_loss) | ["loss", "lr"] |
| `--metrics` | Metrics to log (e.g. --metrics loss lr val_loss) | ["loss", "lr", "grad_norm"] |
### Gradient Checkpointing
@@ -86,11 +96,23 @@
| Parameter | Description | Default | Used by |
|-----------|-------------|---------|---------|
| `--dpo_beta` | DPO beta value | 0.1 | `dpo` |
| `--label_smoothing` | Label smoothing for cross-entropy loss | 0.05 | `seq`, `sft` |
| `--label_smoothing` | Label smoothing for cross-entropy loss | 0.0 | `seq`, `sft` |
| `--group_size` | GRPO group size | 4 | `grpo` |
| `--grpo_clip_eps` | GRPO clipping epsilon | 0.2 | `grpo` |
| `--grpo_kl_coef` | GRPO KL penalty coefficient | 0.01 | `grpo` |
| `--grpo_sync_interval` | GRPO ref_model sync interval (steps) | 200 | `grpo` |
| `--neftune_alpha` | NEFTune noise alpha (0=disabled, typical: 5.0) | 0.0 | `sft` |
### Scheduler
| Parameter | Description | Default |
|-----------|-------------|---------|
| `--schedule_type` | LR scheduler type (`cosine`, `sgdr`, `wsd`) | cosine |
| `--min_rate` | Minimum LR as fraction of base LR | None (scheduler default: 0.01) |
| `--cycle_length` | SGDR first cycle length in steps | None (total_steps - warmup_steps) |
| `--t_mult` | SGDR cycle length multiplier per restart | 2 |
| `--stable_steps` | WSD stable plateau steps | None (required for wsd) |
| `--decay_steps` | WSD decay steps | None (total_steps - warmup_steps - stable_steps) |
### Usage Example
@@ -108,9 +130,7 @@ nohup python scripts/tools/train.py \
--warmup_ratio=0.05 \
--max_lr=1e-4 \
--max_grad_norm=1.0 \
--adamw_beta1=0.9 \
--adamw_beta2=0.95 \
--adamw_weight_decay=0.01 \
--weight_decay=0.1 \
--window_size=2048 \
--ckpt_interval=10000 \
--ckpt_dir=./checkpoint \
@@ -121,4 +141,64 @@ nohup python scripts/tools/train.py \
---
> Document Update Time: 2026-05-24
## Inference Server (`server.py`)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--host` | str | `0.0.0.0` | Host address |
| `--port` | int | `8000` | Port number |
| `--param_path` | path | `project_root/params` | Path to model parameters |
| `--device` | str | `cuda` | Device to load model on |
| `--dtype` | str | `bfloat16` | Model weights dtype (`bfloat16`, `float16`, `float32`) |
| `--max_batch_size` | int | `16` | Maximum batch size for continuous batching |
| `--reload` | flag | `False` | Enable auto-reload for development |
Usage:
```bash
python scripts/tools/server.py --param_path ./params --device cuda --dtype bfloat16
```
See [Inference Guide](inference.md) for HTTP API documentation.
## Generate (`generate.py`)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `--param_path` | str | required | Path to the model directory |
| `--input_json_file` | str | required | Path to the input JSONL file |
| `--output_json_file` | str | required | Path to the output JSONL file |
| `--question_key` | str | `question` | Key for the question in input JSON |
| `--response_key` | str | `response` | Key for the response in output JSON |
| `--temperature` | float | `0.60` | Sampling temperature |
| `--top_k` | int | `30` | Top-k filtering |
| `--top_p` | float | `0.95` | Nucleus sampling threshold |
| `--batch_size` | int | `1` | Batch size for generation |
| `--max_tokens` | int | model config `max_len` | Maximum tokens to generate |
Usage:
```bash
python scripts/tools/generate.py \
--param_path ./params \
--input_json_file input.jsonl \
--output_json_file output.jsonl
```
## Preprocess (`preprocess.py`)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `input_files` | path(s) | required | Input JSONL file(s), supports glob (`data/*.jsonl`) |
| `--output_dir`, `-o` | path | required | Output directory for processed data |
| `--config`, `-c` | path | required | Preprocessing pipeline config (JSON) |
| `--tokenizer_path` | str | `params` | Path to tokenizer directory |
Usage:
```bash
python scripts/tools/preprocess.py data/*.jsonl -o output/ -c sft.json
```
See [Preprocessing Guide](preprocessing.md) for config file format and examples.
---
> Document Update Time: 2026-07-09
+26 -12
View File
@@ -2,6 +2,17 @@
Declarative JSON-driven data preprocessing. One `SectionedMaskBuilder` handles all formats via `input.sections` (single-output) or `input.sources` (multi-output).
## Contents
- [Philosophy](#philosophy)
- [Config Structure](#config-structure)
- [Quick Start](#quick-start) — SFT Chat, SFT Instruction, Pretrain, DPO, GRPO examples
- [Configuration Reference](#configuration-reference) — all fields
- [Mask Algorithm](#mask-algorithm)
- [Output Layout](#output-layout)
- [CLI](#cli)
- [Python API](#python-api)
## Philosophy
| Component | Responsibility |
@@ -15,8 +26,9 @@ A single config file captures the entire pipeline, reusable and version-controll
```json
{
"version": 1,
"input": {}, // sections (single) or sources (multi)
"mask": {}, // role "train" | "mask"
"mask": {}, // role -> "train" | "mask"
"mask_default": "mask",
"preprocessing": {},
"output": {}
@@ -209,11 +221,12 @@ Config:
}
```
Output keys: `prompts`, `responses`, `masks`, `rewards` (float32)
Output keys: `prompts`, `prompts_mask`, `responses`, `masks`, `rewards` (float32)
- `action: "value"` — extract raw values from JSONL without tokenisation
- `list_field: true` — tokenise each list element independently, then concatenate
- `mask_key: "masks"` — rename the auto-generated mask key (default: `responses_mask`)
- `prompts_mask` is auto-generated (all masked) and unused by GRPOStrategy
---
@@ -255,7 +268,7 @@ When `sources` is set, `sections` is ignored.
| `storage_format` | str | `"bin"` | `"bin"` (mmap) or `"h5"` |
| `max_tokens_per_shard` | int | `100000000` | Flush threshold in cumulative tokens |
| `dtype` | dict[str, str] | `{}` | Per-key tensor dtype override (e.g. `{"loss_mask": "bool"}`) |
| `position_ids_mode` | str | `"none"` | How to compute position_ids: `"none"`, `"doc_reset"`, `"continuous"` |
| `position_ids_mode` | str | `"doc_reset"` | How to compute position_ids: `"none"`, `"doc_reset"`, `"continuous"` |
---
@@ -263,12 +276,11 @@ When `sources` is set, `sections` is ignored.
### Template mode (`template: true`)
For each message in the field's array:
1. Prepend BOS token (masked)
2. Render through `chat_template` for that single message
3. Encode rendered text
4. Apply mask rule for the message's role
2. For each message in the field's array:
1. Render through `chat_template` for that single message
2. Encode rendered text
3. Apply mask rule for the message's role
### Non-template mode
@@ -276,7 +288,7 @@ Encode the field value as text. Mask value is 1 (train) or 0 (mask) per the sect
### Text config detection
When no section uses `template` and all sections have `action: "train"`, the builder skips mask generation entirely — all tokens are trained.
When no section uses `template` and all sections have `action: "train"`, the builder omits `loss_mask` from the output — all tokens are trained.
---
@@ -287,10 +299,12 @@ When no section uses `template` and all sections have `action: "train"`, the bui
```
output/
__default__/
shard_0000/
meta.json
sequence.bin
loss_mask.bin
wiki/
shard_0000/
meta.json
sequence.bin
loss_mask.bin
@@ -313,7 +327,7 @@ output/
loss_mask.bin
```
`MmapStore` discovers all shards under the domain directory via `rglob("meta.json")`.
For `bin` format, `MmapStore` discovers all shards under the domain directory via `rglob("meta.json")`. For `h5` format, `H5Store` discovers `.h5`/`.hdf5` files via recursive glob.
---
@@ -338,7 +352,7 @@ python scripts/tools/preprocess.py data/grpo/*.jsonl -o output/grpo/ -c configs/
from astrai.preprocessing.pipeline import Pipeline
from astrai.config.preprocess_config import PipelineConfig
config = PipelineConfig.from_json("sft.json")
config = PipelineConfig.from_file("sft.json")
Pipeline(
config,
["data_part1.jsonl", "data_part2.jsonl"],
@@ -347,4 +361,4 @@ Pipeline(
).run()
```
> Document Update Time: 2026-06-03
> Document Update Time: 2026-07-09
+29 -15
View File
@@ -1,5 +1,18 @@
# Training
## Contents
- [Autoregression](#autoregression)
- [Causal Mask](#causal-mask)
- [Rotary Position Embedding (RoPE)](#rotary-position-embedding-rope)
- [Training Loop](#training-loop)
- [Strategies](#strategies) — SEQ, SFT, DPO, GRPO
- [LR Schedulers](#lr-schedulers)
- [Gradient Checkpointing](#gradient-checkpointing)
- [Checkpoint](#checkpoint)
- [TrainContextBuilder](#traincontextbuilder-builder-pattern)
- [Training CLI](#training-cli)
### Autoregression
Given a token sequence, the model predicts the probability of the next token. Each generated token is appended to the input and fed back, repeating until an end-of-sequence token or max length.
@@ -45,7 +58,9 @@ on_train_begin
context.loss = loss.item()
stand_loss = loss / executor.grad_accum_steps
executor.backward(stand_loss)
context.iteration += 1
context.consumed_samples += (
context.config.batch_per_device * context.world_size
)
on_batch_end
if executor.sync_gradients:
@@ -65,13 +80,13 @@ on_train_end
| `on_train_begin` | Before training starts | `GradientCheckpointingCallback` |
| `on_epoch_begin` | Start of each epoch | `ProgressBarCallback` |
| `on_batch_begin` | Every batch | — |
| `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `ValidationCallback` |
| `on_batch_end` | Every batch | `CheckpointCallback`, `MetricLoggerCallback`, `ProgressBarCallback` |
| `on_epoch_end` | End of each epoch | `ProgressBarCallback` |
| `on_error` | On exception during training | `CheckpointCallback`, `MetricLoggerCallback` |
| `on_train_end` | Training ends (always via finally) | `CheckpointCallback`, `MetricLoggerCallback`, `GradientCheckpointingCallback` |
| `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `MetricCallback`, `ProgressBarCallback` |
| `on_batch_end` | Every batch | `CheckpointCallback` |
| `on_epoch_end` | End of each epoch | `MetricCallback`, `ProgressBarCallback` |
| `on_error` | On exception during training | `CheckpointCallback`, `MetricCallback` |
| `on_train_end` | Training ends (always via finally) | `CheckpointCallback`, `MetricCallback`, `GradientCheckpointingCallback` |
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric_logger` (JSONL, rank-0), `progress_bar` (tqdm), `gradient_clipping`, `validation` (periodic validation on val_dataset).
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric` (JSONL + validation, rank-0), `progress_bar` (tqdm), `gradient_clipping`.
## Strategies
@@ -93,7 +108,7 @@ $$
L_{\text{SFT}} = -\sum_{t=P+1}^{P+L} \log P(s_t \mid s_{\lt t}; \theta)
$$
Keys: `input_ids`, `target_ids`, `loss_mask`. Optional: `label_smoothing`.
Keys: `input_ids`, `target_ids`, `loss_mask`, `position_ids`. Optional: `label_smoothing`.
### DPO (Direct Preference Optimization)
@@ -127,8 +142,9 @@ Keys: `prompts`, `responses`, `masks`, `rewards`.
|------|-------|-------------|
| Cosine | `CosineScheduler` | Linear warmup → cosine decay to `min_rate` |
| SGDR | `SGDRScheduler` | Cosine annealing with warm restarts (`t_mult=2`) |
| WSD | `WSDScheduler` | Warmup-Stable-Decay with sqrt cooldown |
Created by `SchedulerFactory.create(optimizer, schedule_type, **kwargs)`. Valid types: `"cosine"`, `"sgdr"`. Omit to use no scheduler.
Created by `SchedulerFactory.create(schedule_type, optimizer, **kwargs)`. Valid types: `"cosine"`, `"sgdr"`, `"wsd"`. Omit to use no scheduler.
## Gradient Checkpointing
@@ -144,8 +160,8 @@ Callback wraps each `DecoderBlock.forward` with `torch.utils.checkpoint.checkpoi
## Checkpoint
```
Checkpoint(state_dict, epoch, iteration, extra, meta, config)
├── save(save_dir) rank-0 only: meta.json (epoch/iteration/timestamp) + config.json (model config) + model.safetensors + optional {key}.pt (optimizer.pt, scheduler.pt)
Checkpoint(state_dict, epoch, consumed_samples, extra, meta, config)
├── save(save_dir) rank-0 only: meta.json (epoch/consumed_samples/timestamp) + config.json (model config) + model.safetensors + optional {key}.pt (optimizer.pt, scheduler.pt)
└── load(save_dir, broadcast=False) loads from local disk; set broadcast=True to broadcast metadata from rank-0
```
@@ -185,9 +201,7 @@ nohup python scripts/tools/train.py \
--warmup_ratio=0.05 \
--max_lr=1e-4 \
--max_grad_norm=1.0 \
--adamw_beta1=0.9 \
--adamw_beta2=0.95 \
--adamw_weight_decay=0.01 \
--weight_decay=0.1 \
--window_size=2048 \
--ckpt_interval=10000 \
--ckpt_dir=./checkpoint \
@@ -198,4 +212,4 @@ nohup python scripts/tools/train.py \
Full parameter reference at [params.md](params.md).
> Document Update Time: 2026-05-30
> Document Update Time: 2026-07-09
+77 -13
View File
@@ -1,34 +1,98 @@
__version__ = "1.3.7"
__version__ = "1.3.9"
__author__ = "ViperEkura"
from astrai.config import (
AutoRegressiveLMConfig,
BaseModelConfig,
ConfigFactory,
EncoderConfig,
PipelineConfig,
TrainConfig,
)
from astrai.dataset import DatasetFactory
from astrai.dataset import (
BaseDataset,
DatasetFactory,
ResumableDistributedSampler,
Store,
StoreFactory,
)
from astrai.factory import BaseFactory
from astrai.inference import (
GenerationRequest,
InferenceEngine,
ProtocolHandler,
SamplingPipeline,
get_app,
run_server,
sample,
)
from astrai.model import (
AutoModel,
AutoRegressiveLM,
EmbeddingEncoder,
LoRAConfig,
inject_lora,
)
from astrai.parallel import (
ExecutorFactory,
get_rank,
get_world_size,
only_on_rank,
spawn_parallel_fn,
)
from astrai.preprocessing import Pipeline, filter_by_length
from astrai.serialization import Checkpoint
from astrai.tokenize import AutoTokenizer, ChatTemplate
from astrai.trainer import (
BaseScheduler,
BaseStrategy,
CallbackFactory,
SchedulerFactory,
StrategyFactory,
TrainCallback,
Trainer,
)
from astrai.model import AutoModel, AutoRegressiveLM
from astrai.tokenize import AutoTokenizer
from astrai.trainer import CallbackFactory, SchedulerFactory, StrategyFactory, Trainer
__all__ = [
"AutoRegressiveLM",
"AutoRegressiveLMConfig",
"EncoderConfig",
"TrainConfig",
"DatasetFactory",
"AutoModel",
"AutoTokenizer",
"BaseDataset",
"BaseFactory",
"BaseModelConfig",
"BaseScheduler",
"BaseStrategy",
"CallbackFactory",
"ChatTemplate",
"Checkpoint",
"ConfigFactory",
"DatasetFactory",
"EmbeddingEncoder",
"EncoderConfig",
"ExecutorFactory",
"GenerationRequest",
"InferenceEngine",
"Trainer",
"CallbackFactory",
"StrategyFactory",
"LoRAConfig",
"Pipeline",
"PipelineConfig",
"ProtocolHandler",
"ResumableDistributedSampler",
"SamplingPipeline",
"SchedulerFactory",
"BaseFactory",
"AutoModel",
"Store",
"StoreFactory",
"StrategyFactory",
"TrainCallback",
"TrainConfig",
"Trainer",
"filter_by_length",
"get_app",
"get_rank",
"get_world_size",
"inject_lora",
"only_on_rank",
"run_server",
"sample",
"spawn_parallel_fn",
]
+3
View File
@@ -20,6 +20,7 @@ class BaseModelConfig(BaseConfig):
"""Base config with ``model_type`` dispatch and file I/O."""
model_type: Optional[str] = None
neftune_alpha: float = 0.0
@dataclass
@@ -70,10 +71,12 @@ class EncoderConfig(BaseModelConfig):
rope_theta: Optional[float] = None
rope_scaling: Optional[dict] = None
attn_type: str = "gqa"
n_heads: Optional[int] = None
n_kv_heads: Optional[int] = None
use_qk_norm: Optional[bool] = None
use_gated_attention: Optional[bool] = None
ffn_type: str = "mlp"
pooling_type: Optional[str] = None
normalize_embeddings: Optional[bool] = None
+1 -1
View File
@@ -96,7 +96,7 @@ class OutputConfig(BaseConfig):
storage_format: str = "bin"
max_tokens_per_shard: int = 100_000_000
dtype: Dict[str, str] = field(default_factory=dict)
position_ids_mode: str = "none"
position_ids_mode: str = "doc_reset"
@dataclass
+8 -8
View File
@@ -47,14 +47,18 @@ class TrainConfig(BaseConfig):
# checkpoint setting
start_epoch: int = field(default=0, metadata={"help": "Start epoch for training."})
start_batch: int = field(
default=0, metadata={"help": "Start batch iteration for training."}
start_samples: int = field(
default=0,
metadata={
"help": "Start samples count (per rank). Superseded by checkpoint consumed_samples."
},
)
ckpt_dir: str = field(
default="./checkpoint", metadata={"help": "Checkpoint directory."}
)
ckpt_interval: int = field(
default=5000, metadata={"help": "Number of iterations between checkpoints."}
default=5000,
metadata={"help": "Number of optimizer steps between checkpoints."},
)
# lora setting
@@ -67,12 +71,8 @@ class TrainConfig(BaseConfig):
log_dir: str = field(
default="./checkpoint/logs", metadata={"help": "Directory for metric logs."}
)
log_interval: int = field(
default=100,
metadata={"help": "Number of batch iterations between metric logs."},
)
metrics: List[str] = field(
default_factory=lambda: ["loss", "lr"],
default_factory=lambda: ["loss", "lr", "grad_norm"],
metadata={"help": "Metrics to record during training."},
)
+4
View File
@@ -5,10 +5,13 @@ from astrai.dataset.dataset import (
from astrai.dataset.sampler import ResumableDistributedSampler
from astrai.dataset.storage import (
H5Store,
JsonlStore,
MmapStore,
Store,
StoreFactory,
detect_format,
)
from astrai.serialization import (
load_bin,
load_h5,
save_bin,
@@ -22,6 +25,7 @@ __all__ = [
"StoreFactory",
"H5Store",
"MmapStore",
"JsonlStore",
"detect_format",
"save_h5",
"load_h5",
+10 -6
View File
@@ -48,24 +48,26 @@ class BaseDataset(Dataset, ABC):
f"Missing: {missing}"
)
def load(self, load_path: str, storage_type: Optional[str] = None):
def load(self, load_path: str, storage_type: Optional[str] = None, **kwargs):
"""Load dataset from the given path.
Auto-detects the storage format if not specified.
Args:
load_path: Path to the data directory or file
storage_type: Force a specific storage type ("h5", "bin"),
storage_type: Force a specific storage type ("h5", "bin", "jsonl"),
or None for auto-detection
**kwargs: Extra arguments forwarded to the store constructor and
to ``store.load()``.
Raises:
KeyError: If the loaded storage is missing required keys.
"""
if storage_type is None:
storage_type = detect_format(load_path)
self.storage = StoreFactory.create(storage_type)
self.storage = StoreFactory.create(storage_type, **kwargs)
self._load_path = load_path
self.storage.load(load_path)
self.storage.load(load_path, **kwargs)
self._validate_keys()
@property
@@ -144,6 +146,7 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
window_size: int,
stride: Optional[int] = None,
storage_type: Optional[str] = None,
**kwargs,
) -> "BaseDataset":
"""Create and load a dataset in one step.
@@ -152,7 +155,8 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
load_path: Path to the data file
window_size: Window size for data sampling
stride: Stride between consecutive samples (default: same as window_size)
storage_type: Storage type ("h5", "bin") or None for auto-detection
storage_type: Storage type ("h5", "bin", "jsonl") or None for auto-detection
**kwargs: Extra arguments forwarded to ``dataset.load()``.
Returns:
Loaded dataset instance
@@ -161,7 +165,7 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
stride = window_size
dataset = cls.create(train_type, window_size, stride)
dataset.load(load_path, storage_type=storage_type)
dataset.load(load_path, storage_type=storage_type, **kwargs)
return dataset
+1
View File
@@ -74,6 +74,7 @@ class ResumableDistributedSampler(Sampler[int]):
self.epoch += 1
self._indices = None
self.iter = self.iter % self.num_samples_per_replica
@property
def _remaining(self):
+111 -65
View File
@@ -20,79 +20,25 @@ Key properties:
import bisect
import glob
import json
import os
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, List, Union
import h5py
import numpy as np
import torch
from torch import Tensor
from astrai.config.preprocess_config import PipelineConfig
from astrai.factory import BaseFactory
from astrai.preprocessing.builder import MaskBuilderFactory
from astrai.preprocessing.position_id import PositionIdStrategyFactory
from astrai.serialization import (
load_bin,
load_h5,
)
from astrai.tokenize import AutoTokenizer
def save_h5(file_path: str, file_name: str, tensor_group: Dict[str, List[Tensor]]):
os.makedirs(file_path, exist_ok=True)
full_file_path = os.path.join(file_path, f"{file_name}.h5")
with h5py.File(full_file_path, "w") as f:
for key, tensors in tensor_group.items():
grp = f.create_group(key)
for idx, tensor in enumerate(tensors):
arr = tensor.cpu().numpy()
grp.create_dataset(f"data_{idx}", data=arr)
def load_h5(file_path: str, share_memory=True) -> Dict[str, List[Tensor]]:
tensor_group: Dict[str, List[Tensor]] = {}
root_path = Path(file_path)
h5_files = list(root_path.rglob("*.h5")) + list(root_path.rglob("*.hdf5"))
for h5_file in h5_files:
with h5py.File(h5_file, "r") as f:
for key in f.keys():
grp = f[key]
dsets = []
for dset_name in grp.keys():
dset = grp[dset_name]
tensor = torch.from_numpy(dset[:])
if share_memory:
tensor = tensor.share_memory_()
dsets.append(tensor)
if tensor_group.get(key) is None:
tensor_group[key] = []
tensor_group[key].extend(dsets)
return tensor_group
def save_bin(file_path: str, tensor_group: Dict[str, List[Tensor]]):
os.makedirs(file_path, exist_ok=True)
meta = {}
for key, tensors in tensor_group.items():
cat = torch.cat(tensors, dim=0)
meta[key] = {"shape": list(cat.shape), "dtype": str(cat.dtype).split(".")[-1]}
np.asarray(cat.cpu().numpy()).tofile(os.path.join(file_path, f"{key}.bin"))
with open(os.path.join(file_path, "meta.json"), "w") as f:
json.dump(meta, f)
def load_bin(file_path: str) -> Dict[str, List[Tensor]]:
with open(os.path.join(file_path, "meta.json"), "r") as f:
meta = json.load(f)
segments: Dict[str, List[Tensor]] = {}
for key, info in meta.items():
arr = np.memmap(
os.path.join(file_path, f"{key}.bin"),
dtype=info["dtype"],
mode="r+",
shape=tuple(info["shape"]),
)
segments[key] = [torch.from_numpy(arr)]
return segments
logger = logging.getLogger(__name__)
def detect_format(load_path: str) -> str:
@@ -102,7 +48,7 @@ def detect_format(load_path: str) -> str:
load_path: Directory or file path
Returns:
Format string ("h5" or "bin")
Format string ("h5", "bin", or "jsonl")
Raises:
FileNotFoundError: If no supported data files are found
@@ -112,6 +58,8 @@ def detect_format(load_path: str) -> str:
suffix = root.suffix.lower()
if suffix in (".h5", ".hdf5"):
return "h5"
if suffix == ".jsonl":
return "jsonl"
raise ValueError(f"Unsupported file format: {suffix}")
h5_files = [
@@ -128,6 +76,11 @@ def detect_format(load_path: str) -> str:
) > 0
if has_meta:
return "bin"
jsonl_files = [
Path(p) for p in glob.glob(str(root / "**" / "*.jsonl"), recursive=True)
]
if jsonl_files:
return "jsonl"
raise FileNotFoundError(f"No supported data files found at {load_path}")
@@ -264,3 +217,96 @@ class MmapStore(Store):
self._normalize(all_raw)
for tensors in self._data.values():
self._mmap_refs.extend(tensors)
@StoreFactory.register("jsonl")
class JsonlStore(Store):
"""On-the-fly tokenization store for raw JSONL files.
A JSONL dataset directory contains ``*.jsonl`` files plus a
``dataset_config.json`` file that follows the same schema as
:class:`PipelineConfig` with an additional ``tokenizer_path`` field.
Records are tokenized when the store is loaded and concatenated into
segmented tensors matching the key layout expected by the dataset
classes (``sequence``, ``loss_mask``, ``position_ids``, ...).
"""
CONFIG_NAME = "dataset_config.json"
def load(self, path: str):
root = Path(path)
config_path = root / self.CONFIG_NAME
if not config_path.exists():
raise FileNotFoundError(
f"JSONL dataset config not found: {config_path}. "
f"Expected {self.CONFIG_NAME} alongside *.jsonl files."
)
with open(config_path, "r", encoding="utf-8") as f:
raw_config = json.load(f)
tokenizer_path = raw_config.pop("tokenizer_path", None)
if tokenizer_path is None:
raise ValueError(
f"JSONL dataset config must specify 'tokenizer_path': {config_path}"
)
self.config = PipelineConfig.from_dict(raw_config)
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
mask_builder = MaskBuilderFactory.create("sectioned")
position_strategy = PositionIdStrategyFactory.create(
self.config.output.position_ids_mode
)
raw: Dict[str, List[Tensor]] = {}
doc_sequences: List[List[int]] = []
for jsonl_path in sorted(root.glob("*.jsonl")):
with open(jsonl_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
logger.warning(
"Failed to parse JSON line in %s, skipping", jsonl_path
)
continue
result = mask_builder.build(item, self.config, tokenizer)
if result is None:
continue
result.pop("domain", None)
primary_ids = self._primary_ids(result)
if not primary_ids:
continue
doc_sequences.append(primary_ids)
for key, ids in result.items():
if key not in raw:
raw[key] = []
raw[key].append(torch.tensor(ids, dtype=self._infer_dtype(ids)))
pos_ids = position_strategy.generate(doc_sequences)
if pos_ids:
raw["position_ids"] = [torch.tensor(pos_ids, dtype=torch.int32)]
self._normalize(raw)
@staticmethod
def _primary_ids(result: dict) -> List[int]:
"""Return the first integer list in *result* as the primary id sequence."""
for val in result.values():
if isinstance(val, list) and val and isinstance(val[0], int):
return val
return []
@staticmethod
def _infer_dtype(ids: List) -> torch.dtype:
"""Infer tensor dtype from the first element of a token/value list."""
if ids and isinstance(ids[0], float):
return torch.float32
return torch.int32
+21
View File
@@ -0,0 +1,21 @@
"""CUDA attention kernel wrappers with torch fallback.
Public API:
- ``attn_decode`` — single-query decode attention
- ``attn_prefill`` — multi-query prefill attention
- ``attn_paged_decode`` — paged decode attention (direct page-table access)
Each wrapper dispatches to its compiled CUDA kernel (``astrai.extension.attn_*``)
when available, otherwise falls back to ``torch.nn.functional.scaled_dot_product_attention``.
"""
from astrai.extension.loader import KERNEL_NAMES, is_available
from astrai.extension.ops import attn_decode, attn_paged_decode, attn_prefill
__all__ = [
"attn_decode",
"attn_paged_decode",
"attn_prefill",
"is_available",
"KERNEL_NAMES",
]
+36
View File
@@ -0,0 +1,36 @@
"""Dynamic discovery and loading of compiled CUDA kernel modules.
Each kernel is registered in ``csrc/build.py`` and built into a ``.so`` placed
in this package directory. On import we try to load each one; kernels that
failed to build (or are running on a CPU-only machine) are marked unavailable
so the wrapper functions can fall back to ``torch`` SDPA.
"""
import importlib
import logging
logger = logging.getLogger(__name__)
KERNEL_NAMES = ["attn_decode", "attn_prefill", "attn_paged_decode"]
_available: dict[str, bool] = {}
_modules: dict[str, object] = {}
for _name in KERNEL_NAMES:
try:
_mod = importlib.import_module(f".{_name}", package=__package__)
_available[_name] = True
_modules[_name] = _mod
except ImportError:
_available[_name] = False
_modules[_name] = None
def is_available(name: str) -> bool:
"""Return ``True`` if the compiled kernel ``name`` was loaded."""
return _available.get(name, False)
def get_module(name: str) -> object:
"""Return the loaded kernel module for ``name``, or ``None`` if unavailable."""
return _modules.get(name)
+149
View File
@@ -0,0 +1,149 @@
"""GQA attention wrapper functions — one entry point per compiled kernel.
Each wrapper dispatches to its CUDA kernel (loaded in ``loader.py``) when
available, otherwise falls back to ``torch`` SDPA.
Add new kernel wrappers here; split into per-variant files only if this file
grows large.
"""
import torch
import torch.nn.functional as F
from astrai.extension.loader import _available, _modules
def _expand_kv_heads(
k: torch.Tensor, v: torch.Tensor, q_head: int
) -> tuple[torch.Tensor, torch.Tensor]:
"""Expand K/V heads to match Q heads for GQA fallback."""
kv_head = k.size(1)
if kv_head == q_head:
return k, v
group = q_head // kv_head
k = k.repeat_interleave(group, dim=1)
v = v.repeat_interleave(group, dim=1)
return k, v
def _torch_fallback(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
mask: torch.Tensor | None,
is_causal: bool,
scale: float | None,
) -> torch.Tensor:
"""Reference attention via ``scaled_dot_product_attention``."""
k, v = _expand_kv_heads(k, v, q.size(1))
attn_mask = mask[:, None, None, :] if mask is not None else None
return F.scaled_dot_product_attention(
q, k, v, attn_mask=attn_mask, is_causal=is_causal and mask is None, scale=scale
)
def _gather_kv_from_pages(
page_table: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
page_size: int,
kv_len: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Gather contiguous K/V from paged cache for torch SDPA fallback.
Shapes:
page_table : [batch, max_pages] (int64)
k_cache : [n_pages, page_size, n_kv_heads, head_dim]
v_cache : same as k_cache
Returns:
k, v : [batch, n_kv_heads, kv_len, head_dim]
"""
batch, max_pages = page_table.shape
n_pages, ps, n_kv_heads, head_dim = k_cache.shape
if ps != page_size:
raise ValueError(f"k_cache page_size mismatch: {ps} vs {page_size}")
k = k_cache.new_empty(batch, n_kv_heads, kv_len, head_dim)
v = v_cache.new_empty(batch, n_kv_heads, kv_len, head_dim)
for b in range(batch):
for pos in range(kv_len):
log_pg = pos // page_size
pg_off = pos % page_size
phys = int(page_table[b, log_pg].item())
k[b, :, pos, :] = k_cache[phys, pg_off, :, :]
v[b, :, pos, :] = v_cache[phys, pg_off, :, :]
return k, v
def attn_decode(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
mask: torch.Tensor | None = None,
is_causal: bool = False,
causal_offset: int = 0,
scale: float | None = None,
) -> torch.Tensor:
if _available["attn_decode"]:
return _modules["attn_decode"].attn_decode(
q,
k,
v,
mask=mask,
is_causal=is_causal,
causal_offset=causal_offset,
scale=scale,
)
return _torch_fallback(q, k, v, mask, is_causal, scale)
def attn_prefill(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
mask: torch.Tensor | None = None,
is_causal: bool = False,
causal_offset: int = 0,
scale: float | None = None,
) -> torch.Tensor:
if _available["attn_prefill"]:
return _modules["attn_prefill"].attn_prefill(
q,
k,
v,
mask=mask,
is_causal=is_causal,
causal_offset=causal_offset,
scale=scale,
)
return _torch_fallback(q, k, v, mask, is_causal, scale)
def attn_paged_decode(
q: torch.Tensor,
page_table: torch.Tensor,
k_cache: torch.Tensor,
v_cache: torch.Tensor,
page_size: int,
kv_len: int,
mask: torch.Tensor | None = None,
is_causal: bool = False,
causal_offset: int = 0,
scale: float | None = None,
) -> torch.Tensor:
if _available["attn_paged_decode"]:
return _modules["attn_paged_decode"].attn_paged_decode(
q,
page_table,
k_cache,
v_cache,
page_size,
kv_len,
mask=mask,
is_causal=is_causal,
causal_offset=causal_offset,
scale=scale,
)
k, v = _gather_kv_from_pages(page_table, k_cache, v_cache, page_size, kv_len)
return _torch_fallback(q, k, v, mask, is_causal, scale)
+1 -2
View File
@@ -4,7 +4,6 @@ import inspect
import sys
from abc import ABC
from typing import (
Any,
Callable,
Dict,
ForwardRef,
@@ -38,7 +37,7 @@ def _resolve_type(
ns = vars(mod)
if isinstance(arg, ForwardRef):
return arg._evaluate(ns, None, frozenset(), recursive_guard=frozenset())
return arg._evaluate(ns, None, recursive_guard=frozenset())
return ns.get(name)
+10 -2
View File
@@ -30,10 +30,14 @@ from astrai.inference.api.openai import OpenAIResponseBuilder
from astrai.inference.core import (
STOP,
Allocator,
CacheView,
ContiguousCache,
ContiguousCacheView,
Executor,
InferenceScheduler,
KVCache,
KvcacheView,
PageCache,
PageCacheView,
PagePool,
PrefixCache,
Storage,
@@ -63,8 +67,12 @@ __all__ = [
"TaskManager",
"TaskStatus",
"Allocator",
"CacheView",
"KVCache",
"KvcacheView",
"ContiguousCache",
"ContiguousCacheView",
"PageCache",
"PageCacheView",
"PagePool",
"PrefixCache",
"Storage",
+10 -2
View File
@@ -2,8 +2,12 @@
from astrai.inference.core.cache import (
Allocator,
CacheView,
ContiguousCache,
ContiguousCacheView,
KVCache,
KvcacheView,
PageCache,
PageCacheView,
PagePool,
PrefixCache,
Storage,
@@ -16,8 +20,12 @@ from astrai.inference.core.task import STOP, Task, TaskManager, TaskStatus
__all__ = [
"Allocator",
"CacheView",
"KVCache",
"KvcacheView",
"ContiguousCache",
"ContiguousCacheView",
"PageCache",
"PageCacheView",
"PagePool",
"PrefixCache",
"Storage",
+138 -7
View File
@@ -1,4 +1,5 @@
import threading
from abc import ABC, abstractmethod
from collections import OrderedDict
from typing import Callable, Dict, List, Optional, Tuple
@@ -62,6 +63,7 @@ class Allocator:
def touch(self, idx: int):
with self._lock:
if idx in self._lru:
self._lru.move_to_end(idx)
@@ -274,7 +276,42 @@ class Storage:
return k, v
class KvcacheView:
class CacheView(ABC):
"""Abstract view passed to attention layers for KV-cache I/O."""
@abstractmethod
def write(self, layer_id: int, k: Tensor, v: Tensor): ...
@abstractmethod
def gather(self, layer_id: int) -> Tuple[Tensor, Tensor]: ...
class KVCache(ABC):
"""Abstract KV-cache facade for scheduler/executor."""
@abstractmethod
def task_alloc(self, task_id: str, prompt_ids: List[int]) -> bool: ...
@abstractmethod
def task_free(self, task_id: str): ...
@abstractmethod
def task_extend(self, task_id: str, pos: int) -> bool: ...
@abstractmethod
def bind_tasks(
self, task_ids: List[str], total_len: int, device: torch.device
) -> CacheView: ...
def task_cached(self, task_id: str) -> int:
return 0
def task_record_hashes(
self, task_id: str, prompt_ids: List[int], start_logical_page: int = 0
): ...
class PageCacheView(CacheView):
"""Bundles Storage + page_table + total_len for attention layers."""
def __init__(self, storage: Storage, page_table: Tensor, total_len: int = 0):
@@ -290,8 +327,8 @@ class KvcacheView:
return self._storage.gather(layer_id, self._page_table, self._total_len)
class KVCache:
"""Facade: page management + KV-cache I/O for continuous batching."""
class PageCache(KVCache):
"""Paged KV-cache with prefix sharing."""
def __init__(
self,
@@ -361,8 +398,102 @@ class KVCache:
for i in range(start_logical_page, full_pages):
self._pool.record(page_table[i], prompt_ids, i)
def make_table_tensor(self, task_ids: List[str], device: torch.device) -> Tensor:
return self._table.table_tensor(task_ids, device)
def bind_tasks(
self, task_ids: List[str], total_len: int, device: torch.device
) -> PageCacheView:
page_table = self._table.table_tensor(task_ids, device)
return PageCacheView(self._storage, page_table, total_len)
def bind(self, page_table: Tensor, total_len: int = 0) -> KvcacheView:
return KvcacheView(self._storage, page_table, total_len)
class ContiguousCacheView(CacheView):
"""Contiguous KV-cache view for attention layers."""
def __init__(
self, cache: "ContiguousCache", batch_indices: Tensor, total_len: int = 0
):
self._cache = cache
self._batch_indices = batch_indices
self._total_len = total_len
def write(self, layer_id: int, k: Tensor, v: Tensor):
seq_len = k.size(1)
start_pos = self._total_len - seq_len
indices = self._batch_indices
self._cache.k[layer_id, indices, start_pos : start_pos + seq_len] = k
self._cache.v[layer_id, indices, start_pos : start_pos + seq_len] = v
new_len = start_pos + seq_len
for s in indices.tolist():
cur = self._cache._slot_len.get(s, 0)
if new_len > cur:
self._cache._slot_len[s] = new_len
def gather(self, layer_id: int) -> Tuple[Tensor, Tensor]:
max_len = max(
self._cache._slot_len.get(int(s), 0) for s in self._batch_indices.tolist()
)
indices = self._batch_indices
k = self._cache.k[layer_id, indices, :max_len]
v = self._cache.v[layer_id, indices, :max_len]
return k, v
class ContiguousCache(KVCache):
"""Contiguous per-slot KV cache (default implementation)."""
def __init__(
self,
n_layers: int,
max_batch_size: int,
max_seq_len: int,
n_kv_heads: int,
head_dim: int,
device: torch.device,
dtype: torch.dtype,
):
self.max_seq_len = max_seq_len
self.k = torch.zeros(
n_layers,
max_batch_size,
max_seq_len,
n_kv_heads,
head_dim,
device=device,
dtype=dtype,
)
self.v = torch.zeros(
n_layers,
max_batch_size,
max_seq_len,
n_kv_heads,
head_dim,
device=device,
dtype=dtype,
)
self._slot_len: Dict[int, int] = {}
self._task_slot: Dict[str, int] = {}
self._free_slots = list(range(max_batch_size))
self._device = device
def task_alloc(self, task_id: str, prompt_ids: List[int]) -> bool:
if not self._free_slots:
return False
slot = self._free_slots.pop(0)
self._task_slot[task_id] = slot
self._slot_len[slot] = 0
return True
def task_free(self, task_id: str):
slot = self._task_slot.pop(task_id, None)
if slot is not None:
self._slot_len.pop(slot, None)
self._free_slots.append(slot)
def task_extend(self, task_id: str, pos: int) -> bool:
return pos < self.max_seq_len
def bind_tasks(
self, task_ids: List[str], total_len: int, device: torch.device
) -> ContiguousCacheView:
slots = [self._task_slot[tid] for tid in task_ids]
batch_indices = torch.tensor(slots, dtype=torch.long, device=device)
return ContiguousCacheView(self, batch_indices, total_len)
+4 -6
View File
@@ -19,13 +19,13 @@ class Executor:
self,
model: AutoModel,
tokenizer: AutoTokenizer,
page_cache: KVCache,
kv_cache: KVCache,
device: Optional[str] = None,
dtype: Optional[torch.dtype] = None,
):
self.model = model
self.tokenizer = tokenizer
self.page_cache = page_cache
self.kv_cache = kv_cache
self.device = device or next(model.parameters()).device
self.dtype = dtype or next(model.parameters()).dtype
@@ -43,7 +43,6 @@ class Executor:
)
task_ids = [t.task_id for t in tasks]
page_tables = self.page_cache.make_table_tensor(task_ids, self.device)
with torch.inference_mode():
self.model(
@@ -53,7 +52,7 @@ class Executor:
)
.unsqueeze(0)
.expand(batch_sz, -1),
paged_cache=self.page_cache.bind(page_tables, total_len=prompt_len),
paged_cache=self.kv_cache.bind_tasks(task_ids, prompt_len, self.device),
)
def execute_decode(self, tasks: List[Task]) -> List[int]:
@@ -72,7 +71,6 @@ class Executor:
total_len = position_ids.max().item() + 1
task_ids = [t.task_id for t in tasks]
page_tables = self.page_cache.make_table_tensor(task_ids, self.device)
temperatures = torch.tensor([t.temperature for t in tasks], device=self.device)
top_ks = torch.tensor([t.top_k for t in tasks], device=self.device)
@@ -81,7 +79,7 @@ class Executor:
with torch.inference_mode():
outputs = self.model(
input_ids.unsqueeze(1),
paged_cache=self.page_cache.bind(page_tables, total_len=total_len),
paged_cache=self.kv_cache.bind_tasks(task_ids, total_len, self.device),
position_ids=position_ids.unsqueeze(1),
)
logits = outputs["logits"][:, -1, :]
+47 -57
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, Optional, Tuple
import torch
from astrai.inference.core.cache import KVCache
from astrai.inference.core.cache import ContiguousCache, KVCache
from astrai.inference.core.executor import Executor
from astrai.inference.core.task import STOP, Task, TaskManager, TaskStatus
from astrai.model.automodel import AutoModel
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
class InferenceScheduler:
"""Four-phase continuous batching loop: cleanup -> refill -> prefill -> decode."""
"""Continuous batching loop: cleanup -> refill -> prefill -> decode (all groups)."""
def __init__(
self,
@@ -23,9 +23,9 @@ class InferenceScheduler:
max_batch_size: int = 16,
max_seq_len: Optional[int] = None,
max_prompt_len: int = 2048,
page_size: int = 64,
device: Optional[str] = None,
dtype: Optional[torch.dtype] = None,
cache: Optional[KVCache] = None,
):
config = model.config
@@ -41,16 +41,17 @@ class InferenceScheduler:
self.device = device or next(model.parameters()).device
self.dtype = dtype or next(model.parameters()).dtype
n_pages = (
max_batch_size * (self.max_seq_len + page_size) + page_size - 1
) // page_size
head_dim = config.dim // config.n_heads
self._page_cache = KVCache(
if cache is not None:
self._cache = cache
else:
self._cache = ContiguousCache(
config.n_layers,
n_pages,
page_size,
max_batch_size,
self.max_seq_len,
config.n_kv_heads,
config.dim // config.n_heads,
head_dim,
self.device,
self.dtype,
)
@@ -65,31 +66,32 @@ class InferenceScheduler:
self._executor = Executor(
model=model,
tokenizer=tokenizer,
page_cache=self._page_cache,
kv_cache=self._cache,
device=self.device,
dtype=self.dtype,
)
self._running = False
self._fatal_error: Optional[Exception] = None
self._stop_event = threading.Event()
self._loop_thread: Optional[threading.Thread] = None
def add_task(self, prompt: str, **kwargs) -> str:
return self._task_mgr.add_task(prompt, **kwargs)
def remove_task(self, task_id: str):
for task in self._task_mgr.remove_task(task_id):
self._page_cache.task_free(task.task_id)
self._cache.task_free(task.task_id)
def get_stats(self) -> Dict[str, Any]:
return self._task_mgr.get_stats()
def _run_generation_loop(self):
stop_ids = self._task_mgr.tokenizer.stop_ids
cache = self._cache
try:
while self._running:
while not self._stop_event.is_set():
finished = self._task_mgr.remove_finished_tasks(stop_ids)
for task in finished:
self._page_cache.task_free(task.task_id)
cache.task_free(task.task_id)
active = self._task_mgr.get_active_tasks()
available = self._task_mgr.max_batch_size - len(active)
@@ -97,7 +99,7 @@ class InferenceScheduler:
candidates = self._task_mgr.pull_candidates(available)
failed = []
for task in candidates:
if self._page_cache.task_alloc(task.task_id, task.prompt_ids):
if cache.task_alloc(task.task_id, task.prompt_ids):
self._task_mgr.activate(task)
else:
failed.append(task)
@@ -112,7 +114,7 @@ class InferenceScheduler:
t
for t in self._task_mgr.get_active_tasks()
if t.output_tokens == 0
and self._page_cache.task_cached(t.task_id) < len(t.prompt_ids)
and cache.task_cached(t.task_id) < len(t.prompt_ids)
]
if to_prefill:
for t in to_prefill:
@@ -122,36 +124,34 @@ class InferenceScheduler:
for t in to_prefill:
key = (
len(t.prompt_ids),
self._page_cache.task_cached(t.task_id),
cache.task_cached(t.task_id),
)
groups.setdefault(key, []).append(t)
for (prompt_len, start_pos), group in groups.items():
self._executor.execute_prefill(group, prompt_len, start_pos)
start_logical_page = start_pos // self._page_cache.page_size
start_logical_page = start_pos // getattr(
cache, "page_size", 64
)
for t in group:
self._page_cache.task_record_hashes(
t.task_id,
t.prompt_ids,
start_logical_page=start_logical_page,
cache.task_record_hashes(
t.task_id, t.prompt_ids, start_logical_page
)
pos_groups: Dict[int, List[Task]] = {}
for t in self._task_mgr.get_active_tasks():
pos_groups.setdefault(t.next_pos, []).append(t)
if pos_groups:
best_key = max(pos_groups, key=lambda k: len(pos_groups[k]))
group = sorted(pos_groups[best_key], key=lambda t: t.task_id)
for next_pos in sorted(pos_groups.keys()):
group = sorted(pos_groups[next_pos], key=lambda t: t.task_id)
valid: List[Task] = []
for t in group:
if self._page_cache.task_extend(t.task_id, t.next_pos):
if cache.task_extend(t.task_id, t.next_pos):
valid.append(t)
else:
t.status = TaskStatus.ABORTED
if t.stream_callback:
t.stream_callback(STOP)
self._task_mgr.invoke_callback(t.task_id, STOP)
if valid:
next_tokens = self._executor.execute_decode(valid)
@@ -159,54 +159,44 @@ class InferenceScheduler:
for t, ntok in zip(valid, next_tokens):
t.output_ids.append(ntok)
t.output_tokens += 1
pos = t.input_tokens + t.output_tokens
extend_ok = self._page_cache.task_extend(t.task_id, pos)
if t.stream_callback:
t.stream_callback(
self._task_mgr.tokenizer.decode([ntok])
self._task_mgr.invoke_callback(
t.task_id,
self._task_mgr.tokenizer.decode([ntok]),
)
if not extend_ok:
t.status = TaskStatus.ABORTED
if t.stream_callback:
t.stream_callback(STOP)
for t in valid:
if t.is_finished(stop_ids):
if t.stream_callback:
t.stream_callback(STOP)
self._task_mgr.invoke_callback(t.task_id, STOP)
except Exception as e:
self._fatal_error = e
self._running = False
self._stop_event.set()
logger.error(f"Scheduler loop crashed: {e}", exc_info=True)
for task in self._task_mgr.get_active_tasks():
if task.stream_callback:
task.stream_callback(STOP)
self._page_cache.task_free(task.task_id)
self._task_mgr.invoke_callback(task.task_id, STOP)
cache.task_free(task.task_id)
for task in self._task_mgr.get_waiting_tasks():
if task.stream_callback:
task.stream_callback(STOP)
self._task_mgr.invoke_callback(task.task_id, STOP)
self._task_mgr.clear_queues()
def start(self):
if not self._running:
self._running = True
if self._loop_thread is not None and self._loop_thread.is_alive():
return
self._stop_event.clear()
t = threading.Thread(target=self._run_generation_loop, daemon=True)
t.start()
self._loop_thread = t
def stop(self):
self._running = False
self._stop_event.set()
self._task_mgr.wake()
if hasattr(self, "_loop_thread"):
if self._loop_thread is not None:
self._loop_thread.join(timeout=2.0)
self._loop_thread = None
for task in self._task_mgr.get_active_tasks():
if task.stream_callback:
task.stream_callback(STOP)
self._page_cache.task_free(task.task_id)
self._task_mgr.invoke_callback(task.task_id, STOP)
self._cache.task_free(task.task_id)
for task in self._task_mgr.get_waiting_tasks():
if task.stream_callback:
task.stream_callback(STOP)
self._task_mgr.invoke_callback(task.task_id, STOP)
self._task_mgr.clear_queues()
if torch.cuda.is_available():
torch.cuda.empty_cache()
+10 -3
View File
@@ -33,7 +33,6 @@ class Task:
temperature: float = 1.0,
top_p: float = 1.0,
top_k: int = 50,
stream_callback: Optional[Callable[[str], None]] = None,
):
self.task_id = task_id
self.prompt_ids = prompt_ids
@@ -48,7 +47,6 @@ class Task:
self.output_tokens: int = 0
self.arrival_time = time.time()
self.finish_time: Optional[float] = None
self.stream_callback = stream_callback
@property
def next_pos(self) -> int:
@@ -79,6 +77,7 @@ class TaskManager:
self.waiting_queue: Deque[Task] = deque()
self.active_tasks: List[Task] = []
self._callbacks: Dict[str, Callable[[str], None]] = {}
self._task_event = threading.Event()
self._lock = threading.Lock()
@@ -117,12 +116,13 @@ class TaskManager:
temperature=temperature,
top_p=top_p,
top_k=top_k,
stream_callback=stream_callback,
)
with self._lock:
self.waiting_queue.append(task)
self._total_tasks += 1
if stream_callback:
self._callbacks[task_id] = stream_callback
self._task_event.set()
return task_id
@@ -134,8 +134,14 @@ class TaskManager:
t for t in self.waiting_queue if t.task_id != task_id
)
self.active_tasks = [t for t in self.active_tasks if t.task_id != task_id]
self._callbacks.pop(task_id, None)
return removed_active
def invoke_callback(self, task_id: str, token: str):
cb = self._callbacks.get(task_id)
if cb:
cb(token)
def get_stats(self) -> Dict[str, Any]:
return {
"total_tasks": self._total_tasks,
@@ -204,6 +210,7 @@ class TaskManager:
with self._lock:
self.waiting_queue.clear()
self.active_tasks.clear()
self._callbacks.clear()
def wake(self):
self._task_event.set()
+3 -1
View File
@@ -8,6 +8,7 @@ from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple,
import torch
import torch.nn as nn
from astrai.inference.core.cache import KVCache
from astrai.inference.core.scheduler import InferenceScheduler
from astrai.inference.core.task import STOP
from astrai.tokenize import AutoTokenizer
@@ -101,6 +102,7 @@ class InferenceEngine:
max_seq_len: Optional[int] = None,
max_prompt_len: int = 2048,
page_size: int = 128,
cache: Optional[KVCache] = None,
):
self.model = model
self.tokenizer = tokenizer
@@ -110,7 +112,7 @@ class InferenceEngine:
max_batch_size=max_batch_size,
max_seq_len=max_seq_len,
max_prompt_len=max_prompt_len,
page_size=page_size,
cache=cache,
)
self.scheduler.start()
+9 -5
View File
@@ -6,7 +6,7 @@ import torch.nn.functional as F
from torch import Tensor
from astrai.factory import BaseFactory
from astrai.inference.core.cache import KvcacheView
from astrai.inference.core.cache import CacheView
from astrai.model.components.linear import Linear
from astrai.model.components.norm import RMSNorm
from astrai.model.components.rope import apply_rotary_emb
@@ -38,6 +38,7 @@ class GQA(nn.Module):
norm_eps: float,
use_gated_attention: bool,
layer_id: int,
n_layers: int = 1,
):
super().__init__()
assert dim % n_heads == 0
@@ -55,7 +56,7 @@ class GQA(nn.Module):
self.q_proj = Linear(dim, n_heads * self.head_dim)
self.k_proj = Linear(dim, n_kv_heads * self.head_dim)
self.v_proj = Linear(dim, n_kv_heads * self.head_dim)
self.o_proj = Linear(dim, dim)
self.o_proj = Linear(dim, dim, init_std=0.02 / (2 * n_layers) ** 0.5)
if self.use_qk_norm:
self.q_norm = RMSNorm(self.head_dim, norm_eps)
@@ -74,7 +75,7 @@ class GQA(nn.Module):
x: Tensor,
rotary_emb: Tensor,
attn_mask: Tensor = None,
paged_cache: Optional[KvcacheView] = None,
paged_cache: Optional[CacheView] = None,
) -> Tensor:
is_causal = attn_mask is None
@@ -121,6 +122,7 @@ class MLA(nn.Module):
use_qk_norm: bool,
use_gated_attention: bool,
layer_id: int,
n_layers: int = 1,
):
super().__init__()
self.dim = dim
@@ -148,7 +150,9 @@ class MLA(nn.Module):
n_kv_heads * (2 * self.head_dim),
)
self.o_proj = Linear(dim, dim, bias=False)
self.o_proj = Linear(
dim, dim, bias=False, init_std=0.02 / (2 * n_layers) ** 0.5
)
if use_gated_attention:
self.gate = Linear(dim, dim, bias=False)
@@ -158,7 +162,7 @@ class MLA(nn.Module):
x: Tensor,
rotary_emb: Tensor,
attn_mask: Tensor = None,
paged_cache: Optional[KvcacheView] = None,
paged_cache: Optional[CacheView] = None,
) -> Tensor:
bsz, seq_len, _ = x.size()
is_causal = attn_mask is None
+10 -30
View File
@@ -1,51 +1,31 @@
from dataclasses import asdict
from typing import Optional
import torch.nn as nn
from torch import Tensor
from astrai.inference.core.cache import KvcacheView
from astrai.inference.core.cache import CacheView
from astrai.model.components.attention import AttnFactory
from astrai.model.components.mlp import FFNFactory
from astrai.model.components.norm import RMSNorm
class DecoderBlock(nn.Module):
def __init__(
self,
dim: int,
n_heads: int,
dim_ffn: int,
n_kv_heads: int,
norm_eps: float,
use_qk_norm: bool,
use_gated_attention: bool,
layer_id: int,
attn_type: str = "gqa",
ffn_type: str = "mlp",
**kwargs,
):
def __init__(self, config, layer_id: int):
super().__init__()
self.attention = AttnFactory.create(
attn_type,
dim=dim,
n_heads=n_heads,
n_kv_heads=n_kv_heads,
use_qk_norm=use_qk_norm,
norm_eps=norm_eps,
use_gated_attention=use_gated_attention,
layer_id=layer_id,
**kwargs,
)
self.input_norm = RMSNorm(dim, norm_eps)
self.post_attention_norm = RMSNorm(dim, norm_eps)
self.mlp = FFNFactory.create(ffn_type, dim, dim_ffn, **kwargs)
cfg = asdict(config)
cfg["down_init_std"] = 0.02 / (2 * config.n_layers) ** 0.5
self.attention = AttnFactory.create(config.attn_type, **cfg, layer_id=layer_id)
self.input_norm = RMSNorm(config.dim, config.norm_eps)
self.post_attention_norm = RMSNorm(config.dim, config.norm_eps)
self.mlp = FFNFactory.create(config.ffn_type, **cfg)
def forward(
self,
x: Tensor,
rotary_emb: Tensor,
attention_mask: Optional[Tensor] = None,
paged_cache: Optional[KvcacheView] = None,
paged_cache: Optional[CacheView] = None,
) -> Tensor:
attn_output = self.attention(
self.input_norm(x),
+5 -2
View File
@@ -7,10 +7,13 @@ from torch import Tensor
class Embedding(nn.Module):
def __init__(self, vocab_size: int, embedding_dim: int):
def __init__(self, vocab_size: int, embedding_dim: int, neftune_alpha: float = 0.0):
super().__init__()
self.weight = nn.Parameter(torch.empty((vocab_size, embedding_dim)))
self.neftune_noise_alpha = 0.0
self.neftune_noise_alpha = neftune_alpha
def set_neftune_alpha(self, alpha: float):
self.neftune_noise_alpha = alpha
def reset_parameters(self):
nn.init.normal_(self.weight, mean=0.0, std=0.02)
+5 -2
View File
@@ -5,13 +5,16 @@ from torch import Tensor
class Linear(nn.Module):
def __init__(self, in_dim: int, out_dim: int, bias: bool = False):
def __init__(
self, in_dim: int, out_dim: int, bias: bool = False, init_std: float = 0.02
):
super().__init__()
self.weight = nn.Parameter(torch.empty((out_dim, in_dim)))
self.bias = nn.Parameter(torch.zeros(out_dim)) if bias else None
self.init_std = init_std
def reset_parameters(self):
nn.init.kaiming_uniform_(self.weight, a=5**0.5)
nn.init.normal_(self.weight, mean=0.0, std=self.init_std)
if self.bias is not None:
fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight)
bound = 1 / (fan_in**0.5)
+13 -4
View File
@@ -13,11 +13,11 @@ class FFNFactory(BaseFactory[nn.Module]):
@FFNFactory.register("mlp")
class MLP(nn.Module):
def __init__(self, dim: int, dim_ffn: int):
def __init__(self, dim: int, dim_ffn: int, down_init_std: float = 0.02):
super().__init__()
self.up = Linear(dim, dim_ffn)
self.gate = Linear(dim, dim_ffn)
self.down = Linear(dim_ffn, dim)
self.down = Linear(dim_ffn, dim, init_std=down_init_std)
def forward(self, x: Tensor) -> Tensor:
gated = self.up(x) * F.silu(self.gate(x))
@@ -35,6 +35,7 @@ class DeepSeekMoE(nn.Module):
n_shared_experts: int = 1,
n_activated_experts: int = 2,
topk_method: str = "greedy",
n_layers: int = 1,
):
super().__init__()
self.dim = dim
@@ -44,12 +45,20 @@ class DeepSeekMoE(nn.Module):
self.topk_method = topk_method
self.router = Linear(dim, n_routed_experts, bias=False)
moe_scale = 1 / max(n_shared_experts, 1) + 1 / n_activated_experts
down_init_std = 0.02 / (2 * n_layers * moe_scale) ** 0.5
self.shared_experts = nn.ModuleList(
[MLP(dim, dim_ffn) for _ in range(n_shared_experts)]
[
MLP(dim, dim_ffn, down_init_std=down_init_std)
for _ in range(n_shared_experts)
]
)
self.routed_experts = nn.ModuleList(
[MLP(dim, dim_ffn) for _ in range(n_routed_experts)]
[
MLP(dim, dim_ffn, down_init_std=down_init_std)
for _ in range(n_routed_experts)
]
)
def forward(self, x: Tensor) -> Tensor:
+4 -14
View File
@@ -23,22 +23,12 @@ class EmbeddingEncoder(AutoModel):
self.rotary_embedding = RotaryEmbedding(
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
)
self.embed_tokens = Embedding(config.vocab_size, config.dim)
self.embed_tokens = Embedding(
config.vocab_size, config.dim, neftune_alpha=config.neftune_alpha
)
self.layers = nn.ModuleList(
[
DecoderBlock(
config.dim,
config.n_heads,
config.dim_ffn,
config.n_kv_heads,
config.norm_eps,
config.use_qk_norm,
config.use_gated_attention,
layer_id,
)
for layer_id in range(config.n_layers)
]
[DecoderBlock(config, layer_id) for layer_id in range(config.n_layers)]
)
self.norm = RMSNorm(config.dim, config.norm_eps)
+6 -25
View File
@@ -5,7 +5,7 @@ import torch.nn as nn
from torch import Tensor
from astrai.config.model_config import AutoRegressiveLMConfig
from astrai.inference.core.cache import KvcacheView
from astrai.inference.core.cache import CacheView
from astrai.model.automodel import AutoModel
from astrai.model.components.decoder_block import DecoderBlock
from astrai.model.components.embedding import Embedding
@@ -59,31 +59,12 @@ class AutoRegressiveLM(AutoModel):
self.rotary_embedding = RotaryEmbedding(
rope_dim, config.max_len, rope_base, rope_scaling=config.rope_scaling
)
self.embed_tokens = Embedding(config.vocab_size, config.dim)
self.embed_tokens = Embedding(
config.vocab_size, config.dim, neftune_alpha=config.neftune_alpha
)
self.layers = nn.ModuleList(
[
DecoderBlock(
config.dim,
config.n_heads,
config.dim_ffn,
config.n_kv_heads,
config.norm_eps,
config.use_qk_norm,
config.use_gated_attention,
layer_id,
attn_type=config.attn_type,
ffn_type=config.ffn_type,
n_routed_experts=config.n_routed_experts,
n_shared_experts=config.n_shared_experts,
n_activated_experts=config.n_activated_experts,
topk_method=config.topk_method,
kv_lora_rank=config.kv_lora_rank,
qk_nope_head_dim=config.qk_nope_head_dim,
qk_rope_head_dim=config.qk_rope_head_dim,
)
for layer_id in range(config.n_layers)
]
[DecoderBlock(config, layer_id) for layer_id in range(config.n_layers)]
)
self.norm = RMSNorm(config.dim, config.norm_eps)
@@ -131,7 +112,7 @@ class AutoRegressiveLM(AutoModel):
self,
input_ids: Tensor,
input_mask: Optional[Tensor] = None,
paged_cache: Optional[KvcacheView] = None,
paged_cache: Optional[CacheView] = None,
position_ids: Optional[Tensor] = None,
) -> Dict[str, Tensor]:
assert input_ids.ndim == 2
+14
View File
@@ -132,6 +132,12 @@ class BaseExecutor:
def grad_accum_steps(self) -> int:
return self.gradient_state.num_steps
def clip_grad_norm(self, model: nn.Module, max_norm: float) -> float:
total_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
if isinstance(total_norm, torch.Tensor):
return total_norm.item()
return total_norm
class ExecutorFactory(BaseFactory[BaseExecutor]):
pass
@@ -260,6 +266,14 @@ class FSDPExecutor(BaseExecutor):
return model.no_sync()
return contextlib.nullcontext()
def clip_grad_norm(self, model: nn.Module, max_norm: float) -> float:
if isinstance(model, FSDP) and self.use_distributed:
total_norm = model.clip_grad_norm_(max_norm)
if isinstance(total_norm, torch.Tensor):
return total_norm.item()
return total_norm
return super().clip_grad_norm(model, max_norm)
def unwrap_model(self, model: nn.Module):
if isinstance(model, FSDP) and self.use_distributed:
with FSDP.state_dict_type(
+16 -5
View File
@@ -1,14 +1,21 @@
import os
import socket
from abc import ABC, abstractmethod
from contextlib import contextmanager
from functools import wraps
from typing import Callable
from typing import Callable, Optional
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
def find_free_port() -> str:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return str(s.getsockname()[1])
def get_current_device():
return os.environ["LOCAL_DEVICE"]
@@ -58,9 +65,11 @@ def setup_parallel(
os.environ["WORLD_SIZE"] = str(world_size)
os.environ["LOCAL_DEVICE"] = str(device_id)
dist.init_process_group(
rank=rank, world_size=world_size, backend=backend, device_id=device_id
)
pg_kwargs = dict(rank=rank, world_size=world_size, backend=backend)
if backend in ("nccl", "ccl"):
pg_kwargs["device_id"] = device_id
dist.init_process_group(**pg_kwargs)
try:
if backend == "nccl" and torch.cuda.is_available():
@@ -215,11 +224,13 @@ def spawn_parallel_fn(
world_size: int,
backend: str = "nccl",
master_addr: str = "localhost",
master_port: str = "29500",
master_port: Optional[str] = None,
device_type: str = "cuda",
start_method: str = "spawn",
**kwargs,
):
if master_port is None:
master_port = find_free_port()
launcher = _detect_launcher()
if launcher in ("torchelastic", "torchrun", "external"):
strategy = TorchrunStrategy(
+4
View File
@@ -1,7 +1,9 @@
from astrai.preprocessing.builder import (
BaseMaskBuilder,
MaskBuilderFactory,
MultiOutputMaskBuilder,
SectionedMaskBuilder,
SingleOutputMaskBuilder,
)
from astrai.preprocessing.packing import (
PackingStrategy,
@@ -20,12 +22,14 @@ from astrai.preprocessing.writer import (
__all__ = [
"BaseMaskBuilder",
"MaskBuilderFactory",
"MultiOutputMaskBuilder",
"PackingStrategy",
"PackingStrategyFactory",
"Pipeline",
"PositionIdStrategy",
"PositionIdStrategyFactory",
"SectionedMaskBuilder",
"SingleOutputMaskBuilder",
"StoreWriter",
"StoreWriterFactory",
"filter_by_length",
+45 -36
View File
@@ -1,8 +1,10 @@
"""Mask building for preprocessing pipeline.
:class:`SectionRenderer` converts section specs into token ids and loss
masks (template / text / value extraction). :class:`SectionedMaskBuilder`
orchestrates single-output / multi-output (DPO / GRPO) assembly.
masks (template / text / value extraction). :class:`SingleOutputMaskBuilder`
handles single-output (SFT / pretrain), :class:`MultiOutputMaskBuilder`
handles multi-output (DPO / GRPO), and :class:`SectionedMaskBuilder`
orchestrates both modes as a façade.
"""
from abc import ABC, abstractmethod
@@ -212,42 +214,17 @@ class MaskBuilderFactory(BaseFactory["BaseMaskBuilder"]):
pass
@MaskBuilderFactory.register("sectioned")
class SectionedMaskBuilder(BaseMaskBuilder):
"""Config-driven builder supporting single and multi-output modes.
@MaskBuilderFactory.register("single")
class SingleOutputMaskBuilder(BaseMaskBuilder):
"""Build a single output sequence with optional loss mask.
Single-output::
{"input": {"sections": [
{"field": "messages", "action": "$role", "template": true}
]}}
{"sequence": [...], "loss_mask": [...], "domain": "..."}
Multi-output (DPO / GRPO)::
{"input": {"sources": {
"chosen": {"sections": [{"field": "chosen", "action": "$role", "template": true}]},
"rejected": {"sections": [{"field": "rejected", "action": "$role", "template": true}]},
}}}
{"chosen": [...], "chosen_mask": [...], "rejected": [...], "rejected_mask": [...], "domain": "..."}
Output spec fields::
sections list of section specs (same format as single-output)
list_field True when JSONL field holds a list (GRPO responses)
mask_key explicit loss-mask output key (default: ``"{output_key}_mask"``)
Expects ``config.input.sections`` (list of section specs).
"""
def __init__(self):
self.renderer = SectionRenderer()
def __init__(self, renderer: Optional[SectionRenderer] = None):
self.renderer = renderer or SectionRenderer()
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
sources_spec = getattr(config.input, "sources", None)
if sources_spec:
return self._build_multi(item, sources_spec, config, tokenizer)
return self._build_single(item, config, tokenizer)
def _build_single(self, item: dict, config, tokenizer) -> Optional[dict]:
sections = config.input.sections
if not sections:
return None
@@ -266,9 +243,22 @@ class SectionedMaskBuilder(BaseMaskBuilder):
result["loss_mask"] = mask
return result
def _build_multi(
self, item: dict, sources_spec: dict, config, tokenizer
) -> Optional[dict]:
@MaskBuilderFactory.register("multi")
class MultiOutputMaskBuilder(BaseMaskBuilder):
"""Build multiple output sequences (DPO / GRPO).
Expects ``config.input.sources`` (dict of output_key → spec).
"""
def __init__(self, renderer: Optional[SectionRenderer] = None):
self.renderer = renderer or SectionRenderer()
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
sources_spec = getattr(config.input, "sources", None)
if not sources_spec:
return None
result: dict = {}
any_output = False
@@ -313,3 +303,22 @@ class SectionedMaskBuilder(BaseMaskBuilder):
result["domain"] = _extract_domain(item, config.output.domain_key)
return result
@MaskBuilderFactory.register("sectioned")
class SectionedMaskBuilder(BaseMaskBuilder):
"""Façade that dispatches to SingleOutputMaskBuilder or MultiOutputMaskBuilder.
Preserves backward compatibility for existing configs and code that rely
on the ``"sectioned"`` factory name.
"""
def __init__(self):
self._single = SingleOutputMaskBuilder()
self._multi = MultiOutputMaskBuilder()
def build(self, item: dict, config, tokenizer) -> Optional[dict]:
sources_spec = getattr(config.input, "sources", None)
if sources_spec:
return self._multi.build(item, config, tokenizer)
return self._single.build(item, config, tokenizer)
+35 -15
View File
@@ -6,8 +6,7 @@ pipeline later flattens the result into contiguous tensors.
"""
from abc import ABC, abstractmethod
from collections import defaultdict
from typing import Dict, List, Tuple
from typing import Dict, List
from astrai.factory import BaseFactory
@@ -53,6 +52,15 @@ class SimplePacking(PackingStrategy):
@PackingStrategyFactory.register("bfd")
class BFDPacking(PackingStrategy):
"""Best-Fit Decreasing bin packing.
Assigns sequences to bins using a best-fit heuristic (sorted by
decreasing length) and concatenates sequences within each bin into
a single packed sequence. Packed sequences are truncated to
*max_packed_len* so that each packed bin fits within one context
window during training.
"""
def apply(
self,
keys: Dict[str, List[List[int]]],
@@ -62,24 +70,40 @@ class BFDPacking(PackingStrategy):
sequences = keys.get("sequence", [])
if not sequences:
return keys
plan = self._plan(sequences, max_packed_len)
reordered: dict = defaultdict(list)
for orig_idx, _ in plan:
bins = self._plan(sequences, max_packed_len, truncation_mode)
packed: Dict[str, List[List[int]]] = {}
for k, vals in keys.items():
reordered[k].append(
_truncate(vals[orig_idx], max_packed_len, truncation_mode)
packed[k] = [
_truncate(
self._concat_bin(vals, bin_indices),
max_packed_len,
truncation_mode,
)
return dict(reordered)
for bin_indices in bins
]
return packed
@staticmethod
def _plan(sequences: List[List[int]], max_packed_len: int) -> List[Tuple[int, int]]:
def _concat_bin(vals: List[List[int]], indices: List[int]) -> List[int]:
result: List[int] = []
for i in indices:
result.extend(vals[i])
return result
@staticmethod
def _plan(
sequences: List[List[int]], max_packed_len: int, truncation_mode: str
) -> List[List[int]]:
n = len(sequences)
order = sorted(range(n), key=lambda i: len(sequences[i]), reverse=True)
bins: List[List[int]] = []
bin_lengths: List[int] = []
for orig_idx in order:
seq_len = min(len(sequences[orig_idx]), max_packed_len)
seq_len = len(
_truncate(sequences[orig_idx], max_packed_len, truncation_mode)
)
best_bin = None
best_remain = max_packed_len + 1
for i, bl in enumerate(bin_lengths):
@@ -94,8 +118,4 @@ class BFDPacking(PackingStrategy):
bins.append([orig_idx])
bin_lengths.append(seq_len)
plan: List[Tuple[int, int]] = []
for bin_indices in bins:
for orig_idx in bin_indices:
plan.append((orig_idx, min(len(sequences[orig_idx]), max_packed_len)))
return plan
return bins
+19 -7
View File
@@ -7,6 +7,7 @@ dispatched by configuration keys.
"""
import json
import logging
import os
from collections import defaultdict
from itertools import chain
@@ -22,6 +23,8 @@ from astrai.preprocessing.position_id import PositionIdStrategyFactory
from astrai.preprocessing.writer import StoreWriterFactory
from astrai.tokenize import AutoTokenizer
logger = logging.getLogger(__name__)
_STR_TO_DTYPE: dict[str, torch.dtype] = {
"bool": torch.bool,
"uint8": torch.uint8,
@@ -88,7 +91,13 @@ class Pipeline:
if pp.max_items and count >= pp.max_items:
break
try:
result = self.transform(item)
except Exception:
logger.warning(
"Failed to process item #%d, skipping", count + 1, exc_info=True
)
continue
if result is None:
continue
@@ -105,7 +114,7 @@ class Pipeline:
continue
bucket = domains[domain]
self._align_bucket(bucket, result, ids, is_multi)
self._align_bucket(bucket, result, ids)
for key, val in result.items():
bucket[key].append(val)
@@ -130,16 +139,12 @@ class Pipeline:
return []
@staticmethod
def _align_bucket(bucket: dict, result: dict, ids: list, is_multi: bool):
def _align_bucket(bucket: dict, result: dict, ids: list):
"""Pad previously-accumulated keys that are missing from *result*."""
for key in list(bucket.keys()):
if key in result:
continue
if is_multi:
pad = bucket[key][-1] if bucket[key] else [1] * len(ids)
bucket[key].append(pad)
else:
bucket[key].append([1] * len(ids))
bucket[key].append([0] * len(ids))
def _iter_items(self):
for path in self.paths:
@@ -155,6 +160,12 @@ class Pipeline:
idx = shard_idx[domain]
pp = self.config.preprocessing
original_sequences = keys.get("sequence", [])
mode = self.config.output.position_ids_mode
if mode == "doc_reset" and original_sequences:
keys["position_ids"] = [list(range(len(s))) for s in original_sequences]
keys = self._packer.apply(dict(keys), pp.max_packed_len, pp.truncation_mode)
tensors: Dict[str, List[torch.Tensor]] = {}
@@ -166,6 +177,7 @@ class Pipeline:
torch.tensor(list(chain.from_iterable(ids_list)), dtype=dt)
]
if mode == "continuous" and original_sequences:
pos_ids = self._position_id.generate(keys.get("sequence", []))
if pos_ids:
tensors["position_ids"] = [torch.tensor(pos_ids, dtype=torch.int32)]
+29 -1
View File
@@ -6,14 +6,18 @@ List[Tensor]}`` dict and delegates the write to the writer selected
by ``output.storage_format``.
"""
import logging
import os
import shutil
from abc import ABC, abstractmethod
from typing import Dict, List
import torch
from astrai.dataset.storage import save_bin, save_h5
from astrai.factory import BaseFactory
from astrai.serialization import save_bin, save_h5
logger = logging.getLogger(__name__)
class StoreWriter(ABC):
@@ -37,11 +41,35 @@ class StoreWriterFactory(BaseFactory["StoreWriter"]):
class BinWriter(StoreWriter):
def save(self, output_dir, domain, shard_idx, tensors):
shard_path = os.path.join(output_dir, domain, f"shard_{shard_idx:04d}")
try:
save_bin(shard_path, tensors)
except Exception:
if os.path.exists(shard_path):
shutil.rmtree(shard_path, ignore_errors=True)
logger.error(
"Failed to write shard %s/%s_%04d, cleaned up partial output",
domain,
"shard",
shard_idx,
exc_info=True,
)
raise
@StoreWriterFactory.register("h5")
class H5Writer(StoreWriter):
def save(self, output_dir, domain, shard_idx, tensors):
chunk_dir = os.path.join(output_dir, domain)
file_path = os.path.join(chunk_dir, f"data_{shard_idx:04d}.h5")
try:
save_h5(chunk_dir, f"data_{shard_idx:04d}", tensors)
except Exception:
if os.path.exists(file_path):
os.remove(file_path)
logger.error(
"Failed to write shard %s/data_%04d.h5, cleaned up partial output",
domain,
shard_idx,
exc_info=True,
)
raise
+43
View File
@@ -0,0 +1,43 @@
"""Serialization utilities for models and datasets.
This package re-exports checkpoint helpers and dataset storage helpers so
that existing imports from ``astrai.serialization`` continue to work.
"""
from astrai.serialization.checkpoint import (
Checkpoint,
load_json,
load_model_config,
load_model_weights,
load_safetensors,
load_state_dict,
load_torch,
save_json,
save_model,
save_safetensors,
save_torch,
)
from astrai.serialization.dataset import (
load_bin,
load_h5,
save_bin,
save_h5,
)
__all__ = [
"Checkpoint",
"load_json",
"load_model_config",
"load_model_weights",
"load_safetensors",
"load_state_dict",
"load_torch",
"save_json",
"save_model",
"save_safetensors",
"save_torch",
"load_bin",
"load_h5",
"save_bin",
"save_h5",
]
@@ -1,5 +1,8 @@
"""Model checkpoint serialization helpers."""
import io
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -136,7 +139,7 @@ def load_state_dict(path: Union[str, Path], broadcast: bool = False) -> dict:
class Checkpoint:
state_dict: Dict[str, Any] = field(default_factory=dict)
epoch: int = 0
iteration: int = 0
consumed_samples: int = 0
extra: Dict[str, Any] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict)
config: Dict[str, Any] = field(default_factory=dict)
@@ -150,7 +153,7 @@ class Checkpoint:
meta = {
"epoch": self.epoch,
"iteration": self.iteration,
"consumed_samples": self.consumed_samples,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
**self.meta,
}
@@ -176,7 +179,7 @@ class Checkpoint:
return cls(
state_dict=state_dict,
epoch=meta.get("epoch", 0),
iteration=meta.get("iteration", 0),
consumed_samples=meta.get("consumed_samples", 0),
extra=extra,
config=config,
)
+76
View File
@@ -0,0 +1,76 @@
"""Dataset storage serialization helpers (HDF5 / memory-mapped binary)."""
import json
import os
from pathlib import Path
from typing import Dict, List
import h5py
import numpy as np
import torch
from torch import Tensor
def save_h5(file_path: str, file_name: str, tensor_group: Dict[str, List[Tensor]]):
os.makedirs(file_path, exist_ok=True)
full_file_path = os.path.join(file_path, f"{file_name}.h5")
with h5py.File(full_file_path, "w") as f:
for key, tensors in tensor_group.items():
grp = f.create_group(key)
for idx, tensor in enumerate(tensors):
arr = tensor.cpu().numpy()
grp.create_dataset(f"data_{idx}", data=arr)
def load_h5(file_path: str, share_memory=True) -> Dict[str, List[Tensor]]:
tensor_group: Dict[str, List[Tensor]] = {}
root_path = Path(file_path)
if root_path.is_file() and root_path.suffix in (".h5", ".hdf5"):
h5_files = [root_path]
else:
h5_files = list(root_path.rglob("*.h5")) + list(root_path.rglob("*.hdf5"))
for h5_file in h5_files:
with h5py.File(h5_file, "r") as f:
for key in f.keys():
grp = f[key]
dsets = []
for dset_name in grp.keys():
dset = grp[dset_name]
tensor = torch.from_numpy(dset[:])
if share_memory:
tensor = tensor.share_memory_()
dsets.append(tensor)
if tensor_group.get(key) is None:
tensor_group[key] = []
tensor_group[key].extend(dsets)
return tensor_group
def save_bin(file_path: str, tensor_group: Dict[str, List[Tensor]]):
os.makedirs(file_path, exist_ok=True)
meta = {}
for key, tensors in tensor_group.items():
cat = torch.cat(tensors, dim=0)
meta[key] = {"shape": list(cat.shape), "dtype": str(cat.dtype).split(".")[-1]}
np.asarray(cat.cpu().numpy()).tofile(os.path.join(file_path, f"{key}.bin"))
with open(os.path.join(file_path, "meta.json"), "w") as f:
json.dump(meta, f)
def load_bin(file_path: str) -> Dict[str, List[Tensor]]:
with open(os.path.join(file_path, "meta.json"), "r") as f:
meta = json.load(f)
segments: Dict[str, List[Tensor]] = {}
for key, info in meta.items():
arr = np.memmap(
os.path.join(file_path, f"{key}.bin"),
dtype=info["dtype"],
mode="r+",
shape=tuple(info["shape"]),
)
segments[key] = [torch.from_numpy(arr)]
return segments
-3
View File
@@ -1,4 +1,3 @@
from astrai.trainer.optim import Muon
from astrai.trainer.schedule import BaseScheduler, SchedulerFactory
from astrai.trainer.strategy import BaseStrategy, StrategyFactory
from astrai.trainer.train_callback import (
@@ -10,8 +9,6 @@ from astrai.trainer.trainer import Trainer
__all__ = [
# Main trainer
"Trainer",
# Optimizer
"Muon",
# Strategy factory
"StrategyFactory",
"BaseStrategy",
+16 -53
View File
@@ -1,42 +1,25 @@
from typing import Any, Callable, Dict
from typing import Dict
import torch
import torch.nn as nn
def _grad_stat(
model: nn.Module, fn: Callable[[torch.Tensor], Any], default: Any
) -> dict:
results = {}
def grad_norm(model: nn.Module, per_param: bool = False) -> float | Dict[str, float]:
grads = [p.grad.detach() for p in model.parameters() if p.grad is not None]
if not grads:
return 0.0
total_sq = torch.stack([g.pow(2).sum() for g in grads]).sum()
if per_param:
norms = {}
for name, param in model.named_parameters():
results[name] = default
if param.grad is not None:
results[name] = fn(param.grad.data)
return results
def grad_norm(model: nn.Module, norm_type: int = 2) -> Dict[str, float]:
return _grad_stat(model, lambda g: g.norm(norm_type).item(), 0.0)
def grad_std(model: nn.Module) -> Dict[str, float]:
return _grad_stat(model, lambda g: g.std().item(), 0.0)
def grad_max(model: nn.Module) -> Dict[str, float]:
return _grad_stat(model, lambda g: g.max().item(), -float("inf"))
def grad_min(model: nn.Module) -> Dict[str, float]:
return _grad_stat(model, lambda g: g.min().item(), float("inf"))
def grad_mean(model: nn.Module) -> Dict[str, float]:
return _grad_stat(model, lambda g: g.mean().item(), 0.0)
def grad_nan_num(model: nn.Module) -> Dict[str, int]:
return _grad_stat(model, lambda g: g.isnan().sum().item(), 0)
norms[name] = param.grad.norm(2).item()
else:
norms[name] = 0.0
norms["total"] = total_sq.sqrt().item()
return norms
return total_sq.sqrt().item()
def ctx_get_loss(ctx):
@@ -52,24 +35,4 @@ def ctx_get_val_loss(ctx):
def ctx_get_grad_norm(ctx):
return grad_norm(ctx.model)
def ctx_get_grad_std(ctx):
return grad_std(ctx.model)
def ctx_get_grad_max(ctx):
return grad_max(ctx.model)
def ctx_get_grad_min(ctx):
return grad_min(ctx.model)
def ctx_get_grad_mean(ctx):
return grad_mean(ctx.model)
def ctx_get_grad_nan_num(ctx):
return grad_nan_num(ctx.model)
return ctx.grad_norm
-143
View File
@@ -1,143 +0,0 @@
import torch
from torch.optim import Optimizer
def _zeropower_via_newtonschulz(G: torch.Tensor, steps: int = 5):
assert G.ndim == 2
X = G
scale = max(1, G.size(0) / G.size(1)) ** 0.5
X = X / (X.norm() + 1e-7) * scale
if steps == 0:
return X
a, b, c = (3.4445, -4.7750, 2.0315)
for _ in range(steps):
A = X @ X.T
B = A @ X
X = a * X + b * B + c * (A @ B)
return X
class Muon(Optimizer):
def __init__(
self,
params,
lr: float = 2e-3,
momentum: float = 0.95,
weight_decay: float = 0.0,
nesterov: bool = True,
ns_steps: int = 5,
adamw_lr: float = None,
adamw_betas: tuple = (0.9, 0.95),
adamw_eps: float = 1e-8,
adamw_wd: float = 0.0,
):
defaults = dict(
lr=lr,
momentum=momentum,
weight_decay=weight_decay,
nesterov=nesterov,
ns_steps=ns_steps,
adamw_lr=adamw_lr if adamw_lr is not None else lr * 0.1,
adamw_betas=adamw_betas,
adamw_eps=adamw_eps,
adamw_wd=adamw_wd,
)
super().__init__(params, defaults)
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
for group in self.param_groups:
params_2d, params_1d = [], []
grads_2d, grads_1d = [], []
for p in group["params"]:
if p.grad is None:
continue
if p.grad.is_sparse:
raise RuntimeError("Muon does not support sparse gradients")
if p.ndim >= 2:
params_2d.append(p)
grads_2d.append(p.grad)
else:
params_1d.append(p)
grads_1d.append(p.grad)
if params_2d:
self._muon_update_foreach(params_2d, grads_2d, group)
if params_1d:
self._adamw_update_foreach(params_1d, grads_1d, group)
return loss
def _muon_update_foreach(self, params_2d, grads_2d, group):
lr = group["lr"]
momentum = group["momentum"]
wd = group["weight_decay"]
nesterov = group["nesterov"]
ns_steps = group["ns_steps"]
if wd != 0:
torch._foreach_mul_(params_2d, 1 - lr * wd)
if nesterov:
grads_2d = torch._foreach_add(grads_2d, params_2d, alpha=wd)
bufs = []
for p, grad in zip(params_2d, grads_2d):
state = self.state[p]
if "momentum_buffer" not in state:
state["momentum_buffer"] = torch.zeros_like(grad)
bufs.append(state["momentum_buffer"])
torch._foreach_lerp_(bufs, grads_2d, 1 - momentum)
for p, buf in zip(params_2d, bufs):
update = _zeropower_via_newtonschulz(buf, steps=ns_steps)
scale = max(1, p.size(0) / p.size(1)) ** 0.5
p.add_(update, alpha=-lr * scale)
def _adamw_update_foreach(self, params_1d, grads_1d, group):
lr = group["adamw_lr"]
betas = group["adamw_betas"]
eps = group["adamw_eps"]
wd = group["adamw_wd"]
steps: list[int] = []
exp_avgs, exp_avg_sqs = [], []
has_state = []
for p in params_1d:
state = self.state[p]
if not state:
state["step"] = 0
state["exp_avg"] = torch.zeros_like(p)
state["exp_avg_sq"] = torch.zeros_like(p)
has_state.append(False)
else:
has_state.append(True)
state["step"] += 1
steps.append(state["step"])
exp_avgs.append(state["exp_avg"])
exp_avg_sqs.append(state["exp_avg_sq"])
beta1, beta2 = betas
torch._foreach_lerp_(exp_avgs, grads_1d, 1 - beta1)
grads_sq = torch._foreach_mul(grads_1d, grads_1d)
torch._foreach_lerp_(exp_avg_sqs, grads_sq, 1 - beta2)
bias_correction1 = [1 - beta1**s for s in steps]
bias_correction2 = [1 - beta2**s for s in steps]
if wd != 0:
torch._foreach_mul_(params_1d, 1 - lr * wd)
exp_avg_corrected = torch._foreach_div(exp_avgs, bias_correction1)
denom = torch._foreach_div(exp_avg_sqs, bias_correction2)
denom = torch._foreach_sqrt(denom)
torch._foreach_add_(denom, eps)
torch._foreach_addcdiv_(params_1d, exp_avg_corrected, denom, value=-lr)
+74 -5
View File
@@ -53,7 +53,7 @@ class CosineScheduler(BaseScheduler):
optimizer,
warmup_steps: int,
lr_decay_steps: int,
min_rate: float = 0.05,
min_rate: float = 0.01,
last_epoch: int = -1,
):
self.warmup_steps = warmup_steps
@@ -65,11 +65,15 @@ class CosineScheduler(BaseScheduler):
def get_lr(self) -> List[float]:
# warmup
if self.last_epoch < self.warmup_steps:
warmup_factor = max(self.min_rate, self.last_epoch / self.warmup_steps)
warmup_factor = max(
self.min_rate, self.last_epoch / max(self.warmup_steps, 1)
)
return [base_lr * warmup_factor for base_lr in self.base_lrs]
# cosine decay
decay_progress = (self.last_epoch - self.warmup_steps) / self.lr_decay_steps
decay_progress = (self.last_epoch - self.warmup_steps) / max(
self.lr_decay_steps, 1
)
decay_progress = min(decay_progress, 1.0)
cosine_decay = 0.5 * (1.0 + math.cos(math.pi * decay_progress))
decay_factor = max(self.min_rate, cosine_decay)
@@ -104,7 +108,7 @@ class SGDRScheduler(BaseScheduler):
optimizer,
warmup_steps: int,
cycle_length: int,
min_rate: float = 0.05,
min_rate: float = 0.01,
t_mult: int = 2,
last_epoch: int = -1,
):
@@ -118,7 +122,9 @@ class SGDRScheduler(BaseScheduler):
def get_lr(self):
# warmup
if self.last_epoch < self.warmup_steps:
warmup_factor = max(self.min_rate, self.last_epoch / self.warmup_steps)
warmup_factor = max(
self.min_rate, self.last_epoch / max(self.warmup_steps, 1)
)
return [base_lr * warmup_factor for base_lr in self.base_lrs]
# SGDR
@@ -164,3 +170,66 @@ class SGDRScheduler(BaseScheduler):
self.min_rate = state_dict.pop("min_rate")
self.t_mult = state_dict.pop("t_mult")
super().load_state_dict(state_dict)
@SchedulerFactory.register("wsd")
class WSDScheduler(BaseScheduler):
"""WSD (Warmup-Stable-Decay) scheduler with sqrt cooldown.
warmup_steps: linear warmup from min_rate to 1.0
stable_steps: constant at base_lr
decay_steps: sqrt decay from base_lr to min_rate
min_rate: minimum lr as fraction of base_lr (default 0.0)
"""
def __init__(
self,
optimizer,
warmup_steps: int,
stable_steps: int,
decay_steps: int,
min_rate: float = 0.01,
last_epoch: int = -1,
):
self.warmup_steps = warmup_steps
self.stable_steps = stable_steps
self.decay_steps = decay_steps
self.min_rate = min_rate
self.total_steps = warmup_steps + stable_steps + decay_steps
super().__init__(optimizer, last_epoch)
def get_lr(self) -> List[float]:
if self.last_epoch < self.warmup_steps:
factor = max(self.min_rate, self.last_epoch / max(self.warmup_steps, 1))
return [base_lr * factor for base_lr in self.base_lrs]
offset = self.last_epoch - self.warmup_steps
if offset < self.stable_steps:
return list(self.base_lrs)
decay_ratio = (offset - self.stable_steps) / max(self.decay_steps, 1)
decay_ratio = min(decay_ratio, 1.0)
factor = (1.0 - self.min_rate) * (1.0 - decay_ratio) ** 2 + self.min_rate
return [base_lr * factor for base_lr in self.base_lrs]
def state_dict(self):
state = super().state_dict()
state.update(
{
"warmup_steps": self.warmup_steps,
"stable_steps": self.stable_steps,
"decay_steps": self.decay_steps,
"min_rate": self.min_rate,
"total_steps": self.total_steps,
}
)
return state
def load_state_dict(self, state_dict):
self.warmup_steps = state_dict.pop("warmup_steps")
self.stable_steps = state_dict.pop("stable_steps")
self.decay_steps = state_dict.pop("decay_steps")
self.min_rate = state_dict.pop("min_rate")
self.total_steps = state_dict.pop("total_steps")
super().load_state_dict(state_dict)
+1 -1
View File
@@ -196,7 +196,7 @@ class SFTStrategy(BaseStrategy):
ignore_index = -100
input_mask = make_doc_boundary_mask(position_ids)
target_ids = target_ids.masked_fill(loss_mask == 0, ignore_index)
target_ids = target_ids.masked_fill(~loss_mask, ignore_index)
logits = self.model(
input_ids=input_ids, position_ids=position_ids, input_mask=input_mask
)["logits"]
+80 -86
View File
@@ -9,7 +9,6 @@ from typing import IO, Callable, List, Optional, Protocol, runtime_checkable
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.nn.utils import clip_grad_norm_
from torch.utils.checkpoint import checkpoint as torch_checkpoint
from tqdm import tqdm
@@ -18,12 +17,7 @@ from astrai.parallel import only_on_rank
from astrai.parallel.setup import get_current_device, get_rank
from astrai.serialization import Checkpoint
from astrai.trainer.metric_util import (
ctx_get_grad_max,
ctx_get_grad_mean,
ctx_get_grad_min,
ctx_get_grad_nan_num,
ctx_get_grad_norm,
ctx_get_grad_std,
ctx_get_loss,
ctx_get_lr,
ctx_get_val_loss,
@@ -86,7 +80,9 @@ class GradientClippingCallback(TrainCallback):
self.max_grad_norm = max_grad_norm
def on_optimizer_step(self, context: TrainContext):
clip_grad_norm_(context.model.parameters(), self.max_grad_norm)
context.grad_norm = context.executor.clip_grad_norm(
context.model, self.max_grad_norm
)
@CallbackFactory.register("gradient_checkpointing")
@@ -143,34 +139,35 @@ class CheckpointCallback(TrainCallback):
self.interval = interval
self.weight_only = weight_only
self.save_extra_fn = save_extra_fn or CheckpointCallback.save_extra
self.last_ckpt_iter = 0
self.last_ckpt_step = 0
def _save_checkpoint(self, context: TrainContext):
state_dict = context.executor.unwrap_model(context.model)
self.last_ckpt_iter = context.iteration
self.last_ckpt_step = context.optimizer_step
if get_rank() == 0:
save_path = os.path.join(
self.save_dir, f"epoch_{context.epoch}_iter_{context.iteration}"
self.save_dir,
f"epoch_{context.epoch}_step_{context.optimizer_step}",
)
extra = self.save_extra_fn(context)
meta = context.config.to_dict()
context.checkpoint = Checkpoint(
state_dict=state_dict,
epoch=context.epoch,
iteration=context.iteration,
consumed_samples=context.consumed_samples,
config=context.model_config,
extra=extra,
meta=meta,
config=context.model_config,
)
context.checkpoint.save(save_path)
def on_batch_end(self, context: TrainContext):
if context.iteration - self.last_ckpt_iter >= self.interval:
if context.optimizer_step - self.last_ckpt_step >= self.interval:
self._save_checkpoint(context)
def on_train_end(self, context: TrainContext):
if context.iteration != self.last_ckpt_iter:
if context.optimizer_step != self.last_ckpt_step:
self._save_checkpoint(context)
def on_error(self, context: TrainContext):
@@ -202,19 +199,23 @@ class ProgressBarCallback(TrainCallback):
@only_on_rank(0)
def on_epoch_begin(self, context: TrainContext):
total_steps = len(context.dataloader) // context.executor.grad_accum_steps
self.progress_bar = tqdm(
context.dataloader,
total=total_steps,
desc=f"Epoch {context.epoch + 1}/{self.num_epoch}",
dynamic_ncols=True,
file=self.file or sys.stdout,
)
@only_on_rank(0)
def on_batch_end(self, context: TrainContext):
def on_optimizer_step(self, context: TrainContext):
postfix = {
"step": context.optimizer_step,
"loss": f"{context.loss:.4f}",
"lr": f"{context.optimizer.param_groups[-1]['lr']:.2e}",
}
if context.grad_norm is not None:
postfix["grad_norm"] = f"{context.grad_norm:.2f}"
if context.val_loss is not None:
postfix["val_loss"] = f"{context.val_loss:.4f}"
self.progress_bar.set_postfix(postfix)
@@ -227,19 +228,20 @@ class ProgressBarCallback(TrainCallback):
self.progress_bar.close()
@CallbackFactory.register("metric_logger")
class MetricLoggerCallback(TrainCallback):
@CallbackFactory.register("metric")
class MetricCallback(TrainCallback):
def __init__(
self,
log_dir: str,
save_interval: int,
log_interval: int = 10,
metrics: List[str] = None,
val_step: int = 0,
):
self.last_log_iter = 0
self.last_log_flush_step = 0
self.save_interval = save_interval
self.log_interval = log_interval
self.metrics = metrics or ["loss", "lr"]
self.val_step = val_step
self._next_val_step = 0
self.log_dir = Path(log_dir) if log_dir else Path.cwd() / "logs"
self.log_dir.mkdir(parents=True, exist_ok=True)
@@ -251,58 +253,28 @@ class MetricLoggerCallback(TrainCallback):
"lr": ctx_get_lr,
"val_loss": ctx_get_val_loss,
"grad_norm": ctx_get_grad_norm,
"grad_std": ctx_get_grad_std,
"grad_max": ctx_get_grad_max,
"grad_min": ctx_get_grad_min,
"grad_mean": ctx_get_grad_mean,
"grad_nan_num": ctx_get_grad_nan_num,
}
def _get_log_data(self, context: TrainContext):
data = {
def _metrics(self, context: TrainContext, names):
return {
m: self._metric_funcs[m](context)
for m in names
if self._metric_funcs[m](context) is not None
}
@only_on_rank(0)
def _append(self, event_type: str, context: TrainContext, **extra):
entry = {
"type": event_type,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"epoch": context.epoch,
"iter": context.iteration,
"step": context.optimizer_step,
"consumed_samples": context.consumed_samples,
**extra,
}
for m in self.metrics:
val = self._metric_funcs[m](context)
if val is not None:
data[m] = val
return data
self.log_cache.append(entry)
@only_on_rank(0)
def _add_log(self, log_data):
self.log_cache.append(log_data)
@only_on_rank(0)
def _save_log(self, epoch, iter):
log_file = self.log_dir / f"epoch_{epoch}_iter_{iter}_metric.jsonl"
log_file.parent.mkdir(parents=True, exist_ok=True)
with open(log_file, "w") as f:
for log in self.log_cache:
f.write(json.dumps(log) + "\n")
def on_batch_end(self, context):
if context.iteration % self.log_interval == 0:
log_data = self._get_log_data(context)
self._add_log(log_data)
if context.iteration - self.last_log_iter >= self.save_interval:
self._save_log(context.epoch, context.iteration)
self.last_log_iter = context.iteration
def on_train_end(self, context):
if context.iteration != self.last_log_iter:
self._save_log(context.epoch, context.iteration)
def on_error(self, context):
self._save_log(context.epoch, context.iteration)
@CallbackFactory.register("validation")
class ValidationCallback(TrainCallback):
def _run_validation(self, context: TrainContext):
def _run_validation(self, context: TrainContext) -> float:
context.model.eval()
total_loss = 0.0
@@ -314,27 +286,49 @@ class ValidationCallback(TrainCallback):
total_loss += loss.item()
num_batches += 1
if context.world_size > 1 and dist.is_initialized():
stats = torch.tensor(
[total_loss, float(num_batches)], device=get_current_device()
)
dist.all_reduce(stats, op=dist.ReduceOp.SUM)
avg_loss = (stats[0] / stats[1]).item()
else:
avg_loss = total_loss / max(num_batches, 1)
if context.world_size > 1 and dist.is_initialized():
loss_tensor = torch.tensor([avg_loss], device=get_current_device())
dist.all_reduce(loss_tensor, op=dist.ReduceOp.AVG)
avg_loss = loss_tensor.item()
context.val_loss = avg_loss
context.model.train()
return avg_loss
step_count = context.iteration // context.config.grad_accum_steps
logger.info(
f"Epoch {context.epoch + 1}, Step {step_count}, Val Loss: {avg_loss:.4f}"
)
@only_on_rank(0)
def _flush(self, epoch, step):
log_file = self.log_dir / f"epoch_{epoch}_step_{step}_metric.jsonl"
log_file.parent.mkdir(parents=True, exist_ok=True)
with open(log_file, "w") as f:
for log in self.log_cache:
f.write(json.dumps(log) + "\n")
def on_optimizer_step(self, context: TrainContext):
if context.val_dataloader is None:
return
cfg = context.config
if cfg.val_step <= 0:
return
step_count = context.iteration // cfg.grad_accum_steps
if step_count % cfg.val_step == 0:
self._run_validation(context)
def on_optimizer_step(self, context):
if (
context.val_dataloader is not None
and self.val_step > 0
and context.optimizer_step >= self._next_val_step
):
context.val_loss = self._run_validation(context)
self._next_val_step = context.optimizer_step + self.val_step
self._append("validation", context, val_loss=context.val_loss)
step_metrics = [m for m in self.metrics if m != "val_loss"]
self._append("step", context, **self._metrics(context, step_metrics))
if context.optimizer_step - self.last_log_flush_step >= self.save_interval:
self._flush(context.epoch, context.optimizer_step)
self.last_log_flush_step = context.optimizer_step
def on_epoch_end(self, context):
self._append("epoch", context)
def on_train_end(self, context):
if context.optimizer_step != self.last_log_flush_step:
self._flush(context.epoch, context.optimizer_step)
def on_error(self, context):
self._flush(context.epoch, context.optimizer_step)
+15 -4
View File
@@ -29,8 +29,9 @@ class TrainContext:
executor: BaseExecutor = field(default=None)
epoch: int = field(default=0)
iteration: int = field(default=0)
consumed_samples: int = field(default=0)
loss: float = field(default=0.0)
grad_norm: Optional[float] = field(default=None)
val_dataloader: Optional[DataLoader] = field(default=None)
val_loss: Optional[float] = field(default=None)
@@ -38,6 +39,14 @@ class TrainContext:
rank: int = field(default=0)
kwargs: Dict[str, Any] = field(default_factory=dict)
@property
def optimizer_step(self) -> int:
return self.consumed_samples // (
self.config.batch_per_device
* self.world_size
* self.config.grad_accum_steps
)
class TrainContextBuilder:
def __init__(
@@ -63,7 +72,6 @@ class TrainContextBuilder:
model = cfg.model_fn()
model = model.to(device=device)
model.embed_tokens.neftune_noise_alpha = cfg.neftune_alpha
model_config = {}
if self._resume_dir:
@@ -90,7 +98,10 @@ class TrainContextBuilder:
if checkpoint.config:
context.model_config = checkpoint.config
context.epoch = checkpoint.epoch or cfg.start_epoch
context.iteration = checkpoint.iteration or cfg.start_batch
if checkpoint.consumed_samples > 0:
context.consumed_samples = checkpoint.consumed_samples
else:
context.consumed_samples = cfg.start_samples * context.world_size
context.checkpoint = checkpoint
if cfg.lora is not None:
@@ -116,7 +127,7 @@ class TrainContextBuilder:
cfg.dataset, [n_train, n_val], generator=generator
)
sampler_offset = context.iteration * cfg.batch_per_device
sampler_offset = context.consumed_samples // context.world_size
sampler = ResumableDistributedSampler(
data_source=train_dataset,
start_epoch=context.epoch,
+5 -4
View File
@@ -35,15 +35,14 @@ class Trainer:
cfg.ckpt_interval,
),
CallbackFactory.create(
"metric_logger",
"metric",
log_dir=cfg.log_dir,
save_interval=cfg.ckpt_interval,
log_interval=cfg.log_interval,
metrics=cfg.metrics,
val_step=cfg.val_step,
),
CallbackFactory.create("progress_bar", cfg.n_epoch),
CallbackFactory.create("gradient_clipping", cfg.max_grad_norm),
CallbackFactory.create("validation"),
]
return callbacks
@@ -74,7 +73,9 @@ class Trainer:
context.loss = loss.item()
stand_loss = loss / executor.grad_accum_steps
executor.backward(stand_loss)
context.iteration += 1
context.consumed_samples += (
context.config.batch_per_device * context.world_size
)
self._call_callbacks("on_batch_end", context)
if executor.sync_gradients:
+2
View File
@@ -0,0 +1,2 @@
# Source directory for CUDA kernels — build-time only.
# Compiled .so files live in astrAI/_ext/.
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
def _arch_flags() -> list[str]:
import torch
if torch.cuda.is_available():
cap = torch.cuda.get_device_capability()
else:
cap = (8, 0)
ver = f"{cap[0]}{cap[1]}"
flags = [f"-gencode=arch=compute_{ver},code=sm_{ver}"]
# tensor-core mma path (mma.sync.m16n8k16.bf16) requires sm_80+; decide the
# kernel dispatch at build time via this define rather than at runtime.
if cap[0] < 8:
flags.append("-DASTRAI_NO_MMA")
return flags
_kernels_dir = Path("csrc/kernels")
REGISTRY: dict[str, dict] = {}
CXX_FLAGS = ["-O3", "-funroll-loops"]
NVCC_FLAGS = [
"-O3",
"--expt-relaxed-constexpr",
"--use_fast_math",
"--ptxas-options=-O3,-v",
"--extra-device-vectorization",
"--threads=8",
]
def register(name: str, sources: list[str] | None = None, **kwargs):
if sources is None:
sources = [str(_kernels_dir / f"{name}.cu")]
REGISTRY[name] = {
"sources": sources,
"cxx_flags": [*CXX_FLAGS],
"nvcc_flags": [*NVCC_FLAGS, *_arch_flags()],
"extra_link_args": kwargs.pop("extra_link_args", []),
**kwargs,
}
register("attn_decode")
register("attn_prefill")
register("attn_paged_decode")
+54
View File
@@ -0,0 +1,54 @@
#pragma once
template<typename T, typename AT = float>
struct AttentionParams {
int batch;
int q_head;
int kv_head;
int q_len;
int kv_len;
int head_dim;
int use_mask;
int is_causal;
int causal_offset;
int num_splits;
float scale;
const T* __restrict__ q;
const T* __restrict__ k;
const T* __restrict__ v;
const bool* __restrict__ mask;
T* __restrict__ o;
AT* __restrict__ o_part;
AT* __restrict__ ml_part;
};
template<typename T, typename AT = float>
struct PagedAttentionParams {
int batch;
int q_head;
int kv_head;
int q_len;
int kv_len;
int head_dim;
int use_mask;
int is_causal;
int causal_offset;
float scale;
int num_splits;
int page_size;
int max_pages;
const T* __restrict__ q;
const T* __restrict__ k_cache;
const T* __restrict__ v_cache;
const bool* __restrict__ mask;
const int64_t* __restrict__ page_table;
T* __restrict__ o;
AT* __restrict__ o_part;
AT* __restrict__ ml_part;
};
+113
View File
@@ -0,0 +1,113 @@
#include "attn_decode_split_kv.cuh"
#include "attn_entry_utils.cuh"
#ifndef ASTRAI_NO_MMA
#include "attn_decode_split_kv_mma.cuh"
#endif
static int decode_num_splits(int base_blocks, int tiles_total) {
int sm_count = 0;
cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, 0);
int n = (2 * sm_count + base_blocks - 1) / base_blocks;
return std::max(1, std::min(n, std::min(tiles_total, 32)));
}
// Scalar fallback: one warp per query head, split-KV across grid.z.
static void launch_scalar_decode(AttentionParams<bf16>& p) {
int group_size = p.q_head / p.kv_head;
int chunks_total = (p.kv_len + DC_CHUNK - 1) / DC_CHUNK;
p.num_splits = decode_num_splits(p.batch * p.kv_head, chunks_total);
auto fopt = torch::TensorOptions().dtype(torch::kFloat32).device(torch::kCUDA);
auto o_part = torch::empty({p.batch, p.q_head, p.num_splits, p.head_dim}, fopt);
auto ml_part = torch::empty({p.batch, p.q_head, p.num_splits, 2}, fopt);
p.o_part = o_part.data_ptr<float>();
p.ml_part = ml_part.data_ptr<float>();
size_t smem = DC_CHUNK * p.head_dim * sizeof(bf16);
attn_decode_split_kv_kernel<<<dim3(p.batch * p.kv_head, 1, p.num_splits), dim3(32, group_size), smem>>>(p);
attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
#ifndef ASTRAI_NO_MMA
// MMA head-packing requires G <= 16 (BR=16 rows). sm_80+ tensor-core
// + cp.async wins even at G=1 (decode is memory-bound, not compute-bound).
// STAGES=2 (double-buffer) for D<=128 (smem 16 KB); STAGES=1 for D=256
// (double-buffer would be 32 KB, near the 48 KB static cap — keep single
// to preserve occupancy).
template <int HEAD_DIM, int BC, int STAGES = (HEAD_DIM <= 128) ? 2 : 1>
static void launch_mma_decode(AttentionParams<bf16>& p) {
int tiles_total = (p.kv_len + BC - 1) / BC;
p.num_splits = decode_num_splits(p.batch * p.kv_head, tiles_total);
auto fopt = torch::TensorOptions().dtype(torch::kFloat32).device(torch::kCUDA);
auto o_part = torch::empty({p.batch, p.q_head, p.num_splits, p.head_dim}, fopt);
auto ml_part = torch::empty({p.batch, p.q_head, p.num_splits, 2}, fopt);
p.o_part = o_part.data_ptr<float>();
p.ml_part = ml_part.data_ptr<float>();
attn_decode_split_kv_mma_kernel<HEAD_DIM, BC, STAGES><<<dim3(p.kv_head, p.batch, p.num_splits), 32>>>(p);
attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
#endif
template <int HEAD_DIM>
static void dispatch_decode(AttentionParams<bf16>& p) {
#ifndef ASTRAI_NO_MMA
int G = p.q_head / p.kv_head;
if (!p.use_mask && G >= 1 && G <= 16) {
launch_mma_decode<HEAD_DIM, 32>(p);
return;
}
#endif
launch_scalar_decode(p);
}
torch::Tensor attn_decode(
torch::Tensor q,
torch::Tensor k,
torch::Tensor v,
c10::optional<torch::Tensor> mask,
bool is_causal = false,
int64_t causal_offset = 0,
c10::optional<double> scale = c10::nullopt
) {
AttentionParams<bf16> p;
attn_pack_params(q, k, v, mask, is_causal, causal_offset, scale, p);
TORCH_CHECK(p.q_len == 1, "Q seq_len must be 1");
TORCH_CHECK(p.head_dim % 32 == 0, "head_dim must be multiple of 32");
auto O = torch::empty_like(q);
p.o = (bf16*)O.data_ptr();
switch (p.head_dim) {
case 32:
dispatch_decode<32>(p);
break;
case 64:
dispatch_decode<64>(p);
break;
case 128:
dispatch_decode<128>(p);
break;
case 256:
dispatch_decode<256>(p);
break;
default:
TORCH_CHECK(false, "decode: unsupported head_dim ", p.head_dim,
" (supported: 32, 64, 128, 256)");
}
return O;
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("attn_decode", &attn_decode,
py::arg("q"),
py::arg("k"),
py::arg("v"),
py::arg("mask") = py::none(),
py::arg("is_causal") = false,
py::arg("causal_offset") = 0,
py::arg("scale") = py::none(),
"GQA decode (tensor-core head-packing on sm_80+, scalar fallback)");
}
+116
View File
@@ -0,0 +1,116 @@
#pragma once
#include <cuda_bf16.h>
#include <float.h>
#include "attn_common.h"
using bf16 = __nv_bfloat16;
constexpr int DC_CHUNK = 64;
__device__ inline float warp_reduce_sum(float val) {
for (int offset = 16; offset > 0; offset >>= 1)
val += __shfl_xor_sync(0xFFFFFFFF, val, offset);
return val;
}
__global__ void attn_decode_split_kv_kernel(AttentionParams<bf16> p) {
int batch = blockIdx.x / p.kv_head;
int kv_head = blockIdx.x % p.kv_head;
int split = blockIdx.z;
int group_size = blockDim.y;
int q_head = kv_head * group_size + threadIdx.y;
int lane = threadIdx.x;
int hd_per_thread = p.head_dim / 32;
float q_reg[8];
int q_off = ((batch * p.q_head + q_head) * 1) * p.head_dim + lane * hd_per_thread;
for (int i = 0; i < hd_per_thread; i++)
q_reg[i] = __bfloat162float(p.q[q_off + i]);
int kv_base = ((batch * p.kv_head + kv_head) * p.kv_len) * p.head_dim;
int mask_base = batch * p.kv_len;
float m = -FLT_MAX, d = 0.0f, acc_reg[8] = {0.0f};
extern __shared__ __align__(16) bf16 k_smem[];
// Split-KV: each split processes a contiguous subset of chunks
int chunks_total = (p.kv_len + DC_CHUNK - 1) / DC_CHUNK;
int chunks_per_split = (chunks_total + p.num_splits - 1) / p.num_splits;
int ch_begin = split * chunks_per_split;
int ch_end = min(chunks_total, ch_begin + chunks_per_split);
for (int ci = ch_begin; ci < ch_end; ci++) {
int chunk_start = ci * DC_CHUNK;
int this_chunk = min(DC_CHUNK, p.kv_len - chunk_start);
int total = this_chunk * p.head_dim;
for (int i = threadIdx.y * 32 + lane; i < total; i += blockDim.x * blockDim.y)
k_smem[i] = p.k[kv_base + chunk_start * p.head_dim + i];
__syncthreads();
for (int s = 0; s < this_chunk; s++) {
float partial = 0.0f;
for (int i = 0; i < hd_per_thread; i++)
partial += q_reg[i] * __bfloat162float(k_smem[s * p.head_dim + lane * hd_per_thread + i]);
partial = warp_reduce_sum(partial) * p.scale;
if (p.use_mask && p.mask && !p.mask[mask_base + chunk_start + s])
partial = -FLT_MAX;
if (p.is_causal && (chunk_start + s) > p.causal_offset)
partial = -FLT_MAX;
float new_m = fmaxf(m, partial);
float alpha = expf(m - new_m);
float beta = expf(partial - new_m);
d = d * alpha + beta;
int v_off = kv_base + (chunk_start + s) * p.head_dim + lane * hd_per_thread;
for (int i = 0; i < hd_per_thread; i++)
acc_reg[i] = acc_reg[i] * alpha + __bfloat162float(p.v[v_off + i]) * beta;
m = new_m;
}
__syncthreads();
}
// ---- write UN-normalised partials for this split ----
size_t bh = (size_t)batch * p.q_head + q_head;
size_t slot = bh * p.num_splits + split;
int d0 = lane * hd_per_thread;
for (int i = 0; i < hd_per_thread; i++) {
int dd = d0 + i;
p.o_part[slot * p.head_dim + dd] = acc_reg[i];
}
if (lane == 0) {
p.ml_part[slot * 2] = m;
p.ml_part[slot * 2 + 1] = d;
}
}
// Reduce split-K partials into the final bf16 output. One block per (batch,
// q_head); each thread folds across all splits with a single-pass
// online-rescale reduction (expf + FMA counts halved vs 3-pass original).
__global__ void attn_decode_combine_kernel(AttentionParams<bf16> p) {
int bh = blockIdx.x;
int d = threadIdx.x;
if (d >= p.head_dim) return;
size_t split_base = (size_t)bh * p.num_splits;
const float* mlp = p.ml_part + split_base * 2;
const float* op = p.o_part + split_base * p.head_dim;
float m = -FLT_MAX, l = 0.0f, acc = 0.0f;
for (int s = 0; s < p.num_splits; s++) {
float mi = mlp[s * 2];
if (mi <= -FLT_MAX) continue;
float li = mlp[s * 2 + 1];
float nm = fmaxf(m, mi);
float corr = __expf(m - nm);
float e = __expf(mi - nm);
acc = acc * corr + op[s * p.head_dim + d] * e;
l = l * corr + li * e;
m = nm;
}
float inv = (l > 1e-20f) ? (1.0f / l) : 0.0f;
p.o[(size_t)bh * p.head_dim + d] = __float2bfloat16(acc * inv);
}
+200
View File
@@ -0,0 +1,200 @@
#pragma once
#include <cfloat>
#include <cuda_bf16.h>
#include "attn_common.h"
#include "attn_mma_utils.cuh"
using bf16 = __nv_bfloat16;
// Split-K (FlashDecoding) tensor-core decode via GQA head-packing.
//
// Decode has q_len == 1, so S = q @ K^T is a GEMV per head — no tensor-core work
// on its own. But GQA gives us G = q_head / kv_head query heads that all share
// one kv_head. We pack those G heads into the M=16 rows of mma.sync.m16n8k16,
// turning G independent GEMVs into a single GEMM that reuses each loaded K/V tile
// across all G heads (K/V load is the decode bottleneck, so the reuse is the win,
// not the flops). The KV sequence is partitioned across gridDim.z blocks so that
// a decode with only batch*kv_head independent tasks can fill all SMs. Each
// (batch, kv_head, split) block computes an UN-normalised partial (Oacc, m, l)
// over its KV slice; the combine kernel below reduces across splits. Fixes the
// "grid too small" bottleneck (0.04 waves/SM → many blocks) for long-context,
// small-batch decode.
//
// Partial layout (float, contiguous):
// o_part : [batch, q_head, num_splits, HEAD_DIM]
// ml_part: [batch, q_head, num_splits, 2] (m, l)
//
// Optimizations:
// - cp.async global→shared for K/V (bypasses registers, cuts instruction count)
// - XOR swizzle (swiz_col): LD=HEAD_DIM, zero waste, no bank conflicts
// - Q loaded directly from global into mma A-operand registers (no sQ staging,
// no prologue syncwarp) — frees shared memory for double-buffering
// - Double-buffered KV (STAGES=2): next tile's cp.async overlaps current
// tile's MMA compute — hides global load latency / boosts bandwidth
// utilization for small-batch (low-occupancy) decode
// - Predicated cp.async (cp_async_16_pred) for full AND partial tiles on one
// uniform path — eliminates the scalar fallback branch
//
// Smem footprint (BC=32): STAGES=2 → 2*(sK+sV) = 2*2*32*HEAD_DIM*2 bytes.
// D=128: 16 KB (fits 48 KB static cap). D=256: 32 KB (also fits).
// STAGES=1 fallback (4/8 KB) for smem-constrained configs.
template <int HEAD_DIM, int BC, int STAGES = 2>
__global__ void attn_decode_split_kv_mma_kernel(AttentionParams<bf16> p) {
constexpr int BR = 16;
constexpr int KD = HEAD_DIM / 16;
constexpr int NC8 = BC / 8;
constexpr int KT2 = BC / 16;
constexpr int DN8 = HEAD_DIM / 8;
constexpr int LD = HEAD_DIM;
constexpr int SWIZ_MASK = (HEAD_DIM >= 64) ? 7 : (HEAD_DIM / 8 - 1);
constexpr int VEC = 8;
constexpr int TOTAL = BC * HEAD_DIM;
const int lane = threadIdx.x;
const int gid = lane >> 2;
const int tid4 = lane & 3;
const int kv_head = blockIdx.x;
const int batch = blockIdx.y;
const int split = blockIdx.z;
const int G = p.q_head / p.kv_head;
const int q_head0 = kv_head * G;
// Double-buffered shared memory for K/V (no sQ needed — Q goes direct
// from global to registers).
__shared__ __align__(16) bf16 sK[STAGES * BC * LD];
__shared__ __align__(16) bf16 sV[STAGES * BC * LD];
// ---- Load Q directly from global into mma A-operand registers ----
// Same layout as prefill: frag[0]/[2] = row gid, frag[1]/[3] = row gid+8
// cols kt*16 + tid4*2 + {0,1} / +{8,9}. pau[0]=cols c,c+1; pau[4]=c+8,c+9.
const int q_base = (batch * p.q_head + q_head0) * HEAD_DIM;
const int qra = gid;
const int qrb = gid + 8;
const bool va = qra < G, vb = qrb < G;
unsigned Qa[KD][4];
#pragma unroll
for (int kt = 0; kt < KD; kt++) {
int c = kt * 16 + tid4 * 2;
const unsigned* pau = reinterpret_cast<const unsigned*>(
&p.q[q_base + qra * HEAD_DIM + c]);
const unsigned* pbu = reinterpret_cast<const unsigned*>(
&p.q[q_base + qrb * HEAD_DIM + c]);
Qa[kt][0] = va ? pau[0] : 0u;
Qa[kt][1] = vb ? pbu[0] : 0u;
Qa[kt][2] = va ? pau[4] : 0u;
Qa[kt][3] = vb ? pbu[4] : 0u;
}
float Oacc[DN8][4];
#pragma unroll
for (int j = 0; j < DN8; j++)
Oacc[j][0] = Oacc[j][1] = Oacc[j][2] = Oacc[j][3] = 0.0f;
float m0 = -FLT_MAX, m1 = -FLT_MAX, l0 = 0.0f, l1 = 0.0f;
const int kv_base = (batch * p.kv_head + kv_head) * p.kv_len * HEAD_DIM;
const int mask_base = batch * p.kv_len;
const int tiles_total = (p.kv_len + BC - 1) / BC;
const int tiles_per_split = (tiles_total + p.num_splits - 1) / p.num_splits;
const int ti_begin = split * tiles_per_split;
const int ti_end = min(tiles_total, ti_begin + tiles_per_split);
const int has_mask = p.use_mask && p.mask;
// ---- Load tile lambda: predicated cp.async, unified full/partial ----
auto load_tile = [&](int ti, int buf) {
int kv0 = ti * BC;
bf16* dK = sK + buf * BC * LD;
bf16* dV = sV + buf * BC * LD;
#pragma unroll
for (int i = lane * VEC; i < TOTAL; i += 32 * VEC) {
int r = i / HEAD_DIM, d = i % HEAD_DIM;
int kc = kv0 + r;
bool valid = kc < p.kv_len;
int off = r * LD + swiz_col(d, r, SWIZ_MASK);
cp_async_16_pred(&dK[off], &p.k[kv_base + kc * HEAD_DIM + d], valid);
cp_async_16_pred(&dV[off], &p.v[kv_base + kc * HEAD_DIM + d], valid);
}
cp_async_commit();
};
// ---- Prologue: issue first tile load ----
if (ti_begin < ti_end) {
load_tile(ti_begin, 0);
}
for (int ti = ti_begin; ti < ti_end; ti++) {
constexpr int BUF_MASK = (STAGES > 1) ? (STAGES - 1) : 0;
int buf = (ti - ti_begin) & BUF_MASK;
// Wait for current tile, then issue next tile's prefetch (overlaps
// with this tile's compute). Single syncwarp covers both hazards.
// When STAGES==1, no prefetch — load happens at end of prior iter.
cp_async_wait_group<0>();
__syncwarp();
if constexpr (STAGES > 1) {
if (ti + 1 < ti_end)
load_tile(ti + 1, (ti + 1 - ti_begin) & BUF_MASK);
}
const bf16* bK = sK + buf * BC * LD;
const bf16* bV = sV + buf * BC * LD;
int kv0 = ti * BC;
float Sacc[NC8][4];
mma_compute_scores<KD, NC8>(Qa, bK, LD, SWIZ_MASK, lane, Sacc);
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++)
Sacc[n8][0] *= p.scale, Sacc[n8][1] *= p.scale,
Sacc[n8][2] *= p.scale, Sacc[n8][3] *= p.scale;
int maxc = p.is_causal ? min(p.kv_len, p.causal_offset + 1) : p.kv_len;
mma_softmax_tile<NC8, DN8>(kv0, maxc, maxc,
mask_base, p.mask, has_mask,
Sacc, Oacc, m0, m1, l0, l1, lane);
mma_pv_accumulate<DN8, KT2>(Sacc, bV, LD, SWIZ_MASK, lane, Oacc);
__syncwarp();
if constexpr (STAGES == 1) {
if (ti + 1 < ti_end)
load_tile(ti + 1, 0);
}
}
// ---- write UN-normalised partials for this split ----
auto split_slot = [&](int h) -> size_t {
size_t bh = (size_t)batch * p.q_head + h;
return bh * p.num_splits + split;
};
#pragma unroll
for (int dn8 = 0; dn8 < DN8; dn8++) {
int d = dn8 * 8 + 2 * tid4;
int r0 = gid, r1 = gid + 8;
if (r0 < G) {
int h = q_head0 + r0;
float* op = p.o_part + split_slot(h) * HEAD_DIM;
op[d] = Oacc[dn8][0];
op[d + 1] = Oacc[dn8][1];
}
if (r1 < G) {
int h = q_head0 + r1;
float* op = p.o_part + split_slot(h) * HEAD_DIM;
op[d] = Oacc[dn8][2];
op[d + 1] = Oacc[dn8][3];
}
}
if (tid4 == 0) {
int r0 = gid, r1 = gid + 8;
if (r0 < G) {
int h = q_head0 + r0;
float* mp = p.ml_part + split_slot(h) * 2;
mp[0] = m0; mp[1] = l0;
}
if (r1 < G) {
int h = q_head0 + r1;
float* mp = p.ml_part + split_slot(h) * 2;
mp[0] = m1; mp[1] = l1;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <torch/extension.h>
#include "attn_common.h"
template<typename T>
inline void attn_pack_params(
torch::Tensor q,
torch::Tensor k,
torch::Tensor v,
c10::optional<torch::Tensor> mask,
bool is_causal,
int64_t causal_offset,
c10::optional<double> scale,
AttentionParams<T>& p
) {
TORCH_CHECK(q.is_cuda() && k.is_cuda() && v.is_cuda());
TORCH_CHECK(q.dtype() == torch::kBFloat16);
TORCH_CHECK(k.dtype() == torch::kBFloat16);
TORCH_CHECK(v.dtype() == torch::kBFloat16);
p.batch = (int)q.size(0);
p.q_head = (int)q.size(1);
p.kv_head = (int)k.size(1);
p.q_len = (int)q.size(2);
p.kv_len = (int)k.size(2);
p.head_dim = (int)q.size(3);
p.use_mask = mask.has_value() ? 1 : 0;
p.is_causal = is_causal ? 1 : 0;
p.causal_offset = (int)causal_offset;
p.scale = scale.has_value() ? (float)scale.value() : 1.0f / sqrtf((float)p.head_dim);
p.q = (const T*)q.data_ptr();
p.k = (const T*)k.data_ptr();
p.v = (const T*)v.data_ptr();
if (p.use_mask) {
TORCH_CHECK(mask.value().dtype() == torch::kBool);
TORCH_CHECK(mask.value().dim() == 2);
TORCH_CHECK(mask.value().size(0) == p.batch);
TORCH_CHECK(mask.value().size(1) == p.kv_len);
p.mask = mask.value().data_ptr<bool>();
} else {
p.mask = nullptr;
}
}
+252
View File
@@ -0,0 +1,252 @@
#pragma once
#include <cfloat>
#include <cuda_fp16.h>
#include <cuda_runtime.h>
// Shared MMA utilities for tensor-core GQA kernels.
// mma.sync.m16n8k16 PTX wrappers, ldmatrix helpers, and bf16 packing.
// mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32
__device__ __forceinline__ void mma16816(float* d, const unsigned* a,
const unsigned* b, const float* c) {
asm volatile(
"mma.sync.aligned.m16n8k16.row.col.f32.bf16.bf16.f32 "
"{%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%10,%11,%12,%13};"
: "=f"(d[0]), "=f"(d[1]), "=f"(d[2]), "=f"(d[3])
: "r"(a[0]), "r"(a[1]), "r"(a[2]), "r"(a[3]), "r"(b[0]), "r"(b[1]),
"f"(c[0]), "f"(c[1]), "f"(c[2]), "f"(c[3]));
}
// read two adjacent bf16 from smem as one packed .b32 (elem0 low, elem1 high)
__device__ __forceinline__ unsigned ld2(const bf16* p) {
return *reinterpret_cast<const unsigned*>(p);
}
// pack two floats into one bf16x2 as .b32
__device__ __forceinline__ unsigned pk2(float a, float b) {
__nv_bfloat162 v = __floats2bfloat162_rn(a, b);
return *reinterpret_cast<unsigned*>(&v);
}
// pack two (non-contiguous) bf16 into one .b32
__device__ __forceinline__ unsigned pkb(bf16 a, bf16 b) {
__nv_bfloat162 v;
v.x = a;
v.y = b;
return *reinterpret_cast<unsigned*>(&v);
}
// ldmatrix: cooperatively load mma fragments from smem (one instruction per
// 16x16 / 16x8 tile) with the exact register layout mma expects — replaces the
// scalar per-thread fragment packing, cutting shared-load instructions and bank
// conflicts. Each lane supplies the shared address of one 8-wide row.
__device__ __forceinline__ void ldmatrix_x4(unsigned* r, const bf16* p) {
unsigned a = __cvta_generic_to_shared(p);
asm volatile("ldmatrix.sync.aligned.m8n8.x4.shared.b16 {%0,%1,%2,%3}, [%4];"
: "=r"(r[0]), "=r"(r[1]), "=r"(r[2]), "=r"(r[3])
: "r"(a));
}
__device__ __forceinline__ void ldmatrix_x2(unsigned* r, const bf16* p) {
unsigned a = __cvta_generic_to_shared(p);
asm volatile("ldmatrix.sync.aligned.m8n8.x2.shared.b16 {%0,%1}, [%2];"
: "=r"(r[0]), "=r"(r[1])
: "r"(a));
}
__device__ __forceinline__ void ldmatrix_x2_trans(unsigned* r, const bf16* p) {
unsigned a = __cvta_generic_to_shared(p);
asm volatile("ldmatrix.sync.aligned.m8n8.x2.trans.shared.b16 {%0,%1}, [%2];"
: "=r"(r[0]), "=r"(r[1])
: "r"(a));
}
// XOR swizzle for shared-memory column at 8-bf16 chunk granularity.
// Eliminates ldmatrix bank conflicts without LD padding: consecutive rows
// land in distinct bank groups. swiz_col(d, r, mask) = ((d>>3)^(r&mask))<<3 | (d&7).
// mask must cover log2(HEAD_DIM/8) chunk bits but stay within LD: use 7 for
// HEAD_DIM>=64 (8+ chunks), 3 for HEAD_DIM=32 (4 chunks). Default 7 keeps
// existing HEAD_DIM>=64 call sites working unchanged.
__device__ __forceinline__ int swiz_col(int d, int r, int mask = 7) {
return ((d >> 3) ^ (r & mask)) << 3 | (d & 7);
}
// cp.async: copy 16 bytes (8 bf16) from global to shared memory directly,
// bypassing registers. Eliminates shared-store bank conflicts and cuts
// load-loop instruction count in half (1 cp.async vs 1 LDG + 1 STS).
// Requires sm_80+.
__device__ __forceinline__ void cp_async_16(bf16* smem_ptr, const void* gmem_ptr) {
unsigned smem_addr = __cvta_generic_to_shared(smem_ptr);
asm volatile("cp.async.ca.shared.global [%0], [%1], 16;"
:: "r"(smem_addr), "l"(gmem_ptr));
}
// Predicated cp.async: copy 16 bytes when `pred`, otherwise zero-fill the
// destination (src-size operand = 0 → no bytes read from src, so an
// out-of-bounds src address is never dereferenced). Lets full and partial
// tiles share one uniform async load path — no scalar fallback branch.
__device__ __forceinline__ void cp_async_16_pred(bf16* smem_ptr,
const void* gmem_ptr,
bool pred) {
unsigned smem_addr = __cvta_generic_to_shared(smem_ptr);
int src_size = pred ? 16 : 0;
asm volatile("cp.async.ca.shared.global [%0], [%1], 16, %2;"
:: "r"(smem_addr), "l"(gmem_ptr), "r"(src_size));
}
__device__ __forceinline__ void cp_async_commit() {
asm volatile("cp.async.commit_group;");
}
__device__ __forceinline__ void cp_async_wait_all() {
asm volatile("cp.async.wait_all;");
}
// Wait until at most N commit groups are still in flight. Used for
// double-buffered pipelining: wait_group<1> lets the next tile's cp.async
// continue while ensuring the current tile's data is ready.
template <int N>
__device__ __forceinline__ void cp_async_wait_group() {
asm volatile("cp.async.wait_group %0;" :: "n"(N));
}
// ---------------------------------------------------------------------------
// Shared MMA compute functions — used by both decode and prefill MMA kernels.
// Extracted because S=Q@K^T, online softmax, and P@V are structurally identical
// between the two kernels; only the per-row causal/mask bounds differ.
// ---------------------------------------------------------------------------
// S = Q @ K^T (Qa pre-loaded by the caller; scale applied post-mma in the
// caller to avoid bf16 precision loss).
// LD and SWIZ_MASK are constexpr in the calling kernel — passing them as
// runtime ints lets the compiler fold them while keeping the signature clean.
template <int KD, int NC8>
__device__ inline void mma_compute_scores(
const unsigned Qa[KD][4],
const bf16* __restrict__ sK,
int LD, int SWIZ_MASK, int lane,
float Sacc[NC8][4])
{
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++) {
Sacc[n8][0] = Sacc[n8][1] = Sacc[n8][2] = Sacc[n8][3] = 0.0f;
int krow_l = n8 * 8 + (lane & 7);
int kcol_h = (lane & 8) ? 8 : 0;
#pragma unroll
for (int kt = 0; kt < KD; kt++) {
unsigned b[2];
ldmatrix_x2(b, &sK[krow_l * LD + swiz_col(kt * 16 + kcol_h, krow_l, SWIZ_MASK)]);
mma16816(Sacc[n8], Qa[kt], b, Sacc[n8]);
}
}
}
// Online softmax + Oacc rescale for one K/V tile.
// maxc0/maxc1: per-row KV column bounds (prefill: per-query-row causal limits;
// decode: same value for both rows since q_len==1).
// Reads Sacc (Q@K^T scores), applies causal/mask, computes P = exp(S - nm),
// rescales Oacc by exp(m_old - nm), and updates m/l — all in place.
template <int NC8, int DN8>
__device__ inline void mma_softmax_tile(
int kv0,
int maxc0, int maxc1,
int mask_base,
const bool* __restrict__ mask,
bool has_mask,
float Sacc[NC8][4],
float Oacc[DN8][4],
float& m0, float& m1,
float& l0, float& l1,
int lane)
{
int tid4 = lane & 3;
// Mask out-of-bounds / masked columns: set -FLT_MAX so expf → 0 downstream
// without per-element sentinel checks. Compute tile-local row maxima.
float rmax0 = -FLT_MAX, rmax1 = -FLT_MAX;
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++) {
int cc = kv0 + n8 * 8 + 2 * tid4;
int c1 = cc + 1;
bool b0 = (cc >= maxc0) || (has_mask && !mask[mask_base + cc]);
bool b1 = (c1 >= maxc0) || (has_mask && !mask[mask_base + c1]);
bool b2 = (cc >= maxc1) || (has_mask && !mask[mask_base + cc]);
bool b3 = (c1 >= maxc1) || (has_mask && !mask[mask_base + c1]);
float s0 = b0 ? -FLT_MAX : Sacc[n8][0];
float s1 = b1 ? -FLT_MAX : Sacc[n8][1];
float s2 = b2 ? -FLT_MAX : Sacc[n8][2];
float s3 = b3 ? -FLT_MAX : Sacc[n8][3];
Sacc[n8][0] = s0; Sacc[n8][1] = s1;
Sacc[n8][2] = s2; Sacc[n8][3] = s3;
rmax0 = fmaxf(rmax0, fmaxf(s0, s1));
rmax1 = fmaxf(rmax1, fmaxf(s2, s3));
}
// Warp-reduce row maxima across the 4-lane thread group (xor 1, xor 2).
rmax0 = fmaxf(rmax0, __shfl_xor_sync(0xFFFFFFFF, rmax0, 1));
rmax0 = fmaxf(rmax0, __shfl_xor_sync(0xFFFFFFFF, rmax0, 2));
rmax1 = fmaxf(rmax1, __shfl_xor_sync(0xFFFFFFFF, rmax1, 1));
rmax1 = fmaxf(rmax1, __shfl_xor_sync(0xFFFFFFFF, rmax1, 2));
// nm = max(running max m, tile-local max rmax) — updated running maximum.
float nm0 = fmaxf(m0, rmax0), nm1 = fmaxf(m1, rmax1);
// corr rescales Oacc and l by exp(m_old - nm). When all-masked (m == nm ==
// -FLT_MAX), exp(0) = 1 — correct, no guard needed.
float corr0 = __expf(m0 - nm0);
float corr1 = __expf(m1 - nm1);
// pn guards only the all-masked-row edge: if nm == -FLT_MAX, exp(S - nm)
// gives 1 not 0 for masked entries. Two scalar masks replace 4*NC8
// per-element comparisons.
float pn0 = (nm0 == -FLT_MAX) ? 0.0f : 1.0f;
float pn1 = (nm1 == -FLT_MAX) ? 0.0f : 1.0f;
// P = exp(S - nm) for each element. Masked entries (Sacc = -FLT_MAX) give
// exp(-inf) ≈ 0 naturally; pn zero-fills the all-masked-row edge.
float rsum0 = 0.0f, rsum1 = 0.0f;
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++) {
float p0 = pn0 * __expf(Sacc[n8][0] - nm0);
float p1 = pn0 * __expf(Sacc[n8][1] - nm0);
float p2 = pn1 * __expf(Sacc[n8][2] - nm1);
float p3 = pn1 * __expf(Sacc[n8][3] - nm1);
Sacc[n8][0] = p0; Sacc[n8][1] = p1;
Sacc[n8][2] = p2; Sacc[n8][3] = p3;
rsum0 += p0 + p1;
rsum1 += p2 + p3;
}
rsum0 += __shfl_xor_sync(0xFFFFFFFF, rsum0, 1);
rsum0 += __shfl_xor_sync(0xFFFFFFFF, rsum0, 2);
rsum1 += __shfl_xor_sync(0xFFFFFFFF, rsum1, 1);
rsum1 += __shfl_xor_sync(0xFFFFFFFF, rsum1, 2);
l0 = l0 * corr0 + rsum0;
l1 = l1 * corr1 + rsum1;
m0 = nm0; m1 = nm1;
#pragma unroll
for (int j = 0; j < DN8; j++) {
Oacc[j][0] *= corr0; Oacc[j][1] *= corr0;
Oacc[j][2] *= corr1; Oacc[j][3] *= corr1;
}
}
// O += P @ V (Sacc must contain P = attention weights after softmax).
template <int DN8, int KT2>
__device__ inline void mma_pv_accumulate(
float Sacc[][4],
const bf16* __restrict__ sV,
int LD, int SWIZ_MASK, int lane,
float Oacc[DN8][4])
{
#pragma unroll
for (int kt2 = 0; kt2 < KT2; kt2++) {
unsigned Pa[4];
Pa[0] = pk2(Sacc[kt2 * 2][0], Sacc[kt2 * 2][1]);
Pa[1] = pk2(Sacc[kt2 * 2][2], Sacc[kt2 * 2][3]);
Pa[2] = pk2(Sacc[kt2 * 2 + 1][0], Sacc[kt2 * 2 + 1][1]);
Pa[3] = pk2(Sacc[kt2 * 2 + 1][2], Sacc[kt2 * 2 + 1][3]);
int vrow_l = kt2 * 16 + (lane & 15);
#pragma unroll
for (int dn8 = 0; dn8 < DN8; dn8++) {
unsigned b[2];
ldmatrix_x2_trans(b, &sV[vrow_l * LD + swiz_col(dn8 * 8, vrow_l, SWIZ_MASK)]);
mma16816(Oacc[dn8], Pa, b, Oacc[dn8]);
}
}
}
+149
View File
@@ -0,0 +1,149 @@
#include "attn_paged_decode_split_kv.cuh"
#ifndef ASTRAI_NO_MMA
#include "attn_paged_decode_split_kv_mma.cuh"
#endif
#include <torch/extension.h>
#include <c10/cuda/CUDAGuard.h>
static int paged_decode_num_splits(int base_blocks, int tiles_total) {
int sm_count = 0;
cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, 0);
int n = (2 * sm_count + base_blocks - 1) / base_blocks;
return std::max(1, std::min(n, std::min(tiles_total, 32)));
}
static void launch_paged_scalar_decode(PagedAttentionParams<bf16>& p) {
int group_size = p.q_head / p.kv_head;
int chunks_total = (p.kv_len + PDC_CHUNK - 1) / PDC_CHUNK;
p.num_splits = paged_decode_num_splits(p.batch * p.kv_head, chunks_total);
auto fopt = torch::TensorOptions().dtype(torch::kFloat32).device(torch::kCUDA);
auto o_part = torch::empty({p.batch, p.q_head, p.num_splits, p.head_dim}, fopt);
auto ml_part = torch::empty({p.batch, p.q_head, p.num_splits, 2}, fopt);
p.o_part = o_part.data_ptr<float>();
p.ml_part = ml_part.data_ptr<float>();
size_t smem = PDC_CHUNK * p.head_dim * sizeof(bf16);
dim3 grid = dim3(p.batch * p.kv_head, 1, p.num_splits);
dim3 block = dim3(32, group_size);
paged_attn_decode_split_kv_kernel<<<grid, block, smem>>>(p);
paged_attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
#ifndef ASTRAI_NO_MMA
template <int HEAD_DIM, int BC, int STAGES = (HEAD_DIM <= 128) ? 2 : 1>
static void launch_paged_mma_decode(PagedAttentionParams<bf16>& p) {
int tiles_total = (p.kv_len + BC - 1) / BC;
p.num_splits = paged_decode_num_splits(p.batch * p.kv_head, tiles_total);
auto fopt = torch::TensorOptions().dtype(torch::kFloat32).device(torch::kCUDA);
auto o_part = torch::empty({p.batch, p.q_head, p.num_splits, p.head_dim}, fopt);
auto ml_part = torch::empty({p.batch, p.q_head, p.num_splits, 2}, fopt);
p.o_part = o_part.data_ptr<float>();
p.ml_part = ml_part.data_ptr<float>();
paged_attn_decode_split_kv_mma_kernel<HEAD_DIM, BC, STAGES><<<dim3(p.kv_head, p.batch, p.num_splits), 32>>>(p);
paged_attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
#endif
template <int HEAD_DIM>
static void dispatch_paged_decode(PagedAttentionParams<bf16>& p) {
#ifndef ASTRAI_NO_MMA
int G = p.q_head / p.kv_head;
if (!p.use_mask && G >= 1 && G <= 16 && p.page_size >= 32) {
launch_paged_mma_decode<HEAD_DIM, 32>(p);
return;
}
#endif
launch_paged_scalar_decode(p);
}
torch::Tensor attn_paged_decode(
torch::Tensor q,
torch::Tensor page_table,
torch::Tensor k_cache,
torch::Tensor v_cache,
int64_t page_size,
int64_t kv_len,
c10::optional<torch::Tensor> mask,
bool is_causal = false,
int64_t causal_offset = 0,
c10::optional<double> scale = c10::nullopt
) {
const at::cuda::OptionalCUDAGuard device_guard(device_of(q));
int batch = q.size(0);
int q_head = q.size(1);
int head_dim = q.size(3);
int kv_head = k_cache.size(2);
int max_pages = page_table.size(1);
TORCH_CHECK(q.is_cuda() && page_table.is_cuda() && k_cache.is_cuda() && v_cache.is_cuda());
TORCH_CHECK(q.dtype() == torch::kBFloat16, "q must be bf16");
TORCH_CHECK(k_cache.dtype() == torch::kBFloat16, "k_cache must be bf16");
TORCH_CHECK(v_cache.dtype() == torch::kBFloat16, "v_cache must be bf16");
TORCH_CHECK(page_table.dtype() == torch::kLong, "page_table must be int64");
TORCH_CHECK(q.size(2) == 1, "Q seq_len must be 1 (decode)");
TORCH_CHECK(head_dim % 32 == 0, "head_dim must be multiple of 32");
TORCH_CHECK(k_cache.size(1) == page_size,
"k_cache dim 1 must equal page_size, got ",
k_cache.size(1), " vs ", page_size);
TORCH_CHECK(k_cache.size(0) >= 0, "k_cache must have at least 0 pages");
float scale_val = scale.has_value()
? static_cast<float>(scale.value())
: 1.0f / std::sqrt(static_cast<float>(head_dim));
auto O = torch::empty_like(q);
PagedAttentionParams<bf16, float> p;
p.batch = batch;
p.q_head = q_head;
p.kv_head = kv_head;
p.q_len = static_cast<int>(q.size(2));
p.kv_len = static_cast<int>(kv_len);
p.head_dim = head_dim;
p.use_mask = (mask.has_value() && mask.value().defined()) ? 1 : 0;
p.is_causal = is_causal ? 1 : 0;
p.causal_offset = static_cast<int>(causal_offset);
p.scale = scale_val;
p.page_size = static_cast<int>(page_size);
p.max_pages = max_pages;
p.page_table = page_table.data_ptr<int64_t>();
p.k_cache = reinterpret_cast<const bf16*>(k_cache.data_ptr());
p.v_cache = reinterpret_cast<const bf16*>(v_cache.data_ptr());
p.q = reinterpret_cast<const bf16*>(q.data_ptr());
p.mask = p.use_mask ? mask.value().data_ptr<bool>() : nullptr;
p.o = reinterpret_cast<bf16*>(O.data_ptr());
p.o_part = nullptr;
p.ml_part = nullptr;
switch (p.head_dim) {
case 32: dispatch_paged_decode<32>(p); break;
case 64: dispatch_paged_decode<64>(p); break;
case 128: dispatch_paged_decode<128>(p); break;
case 256: dispatch_paged_decode<256>(p); break;
default:
TORCH_CHECK(false, "paged_decode: unsupported head_dim ", p.head_dim,
" (supported: 32, 64, 128, 256)");
}
return O;
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("attn_paged_decode", &attn_paged_decode,
py::arg("q"),
py::arg("page_table"),
py::arg("k_cache"),
py::arg("v_cache"),
py::arg("page_size"),
py::arg("kv_len"),
py::arg("mask") = py::none(),
py::arg("is_causal") = false,
py::arg("causal_offset") = 0,
py::arg("scale") = py::none(),
"Paged GQA decode — split-KV with direct page-table access.");
}
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <cuda_bf16.h>
#include <float.h>
#include "attn_common.h"
using bf16 = __nv_bfloat16;
constexpr int PDC_CHUNK = 64;
__device__ inline float paged_warp_reduce_sum(float val) {
for (int offset = 16; offset > 0; offset >>= 1)
val += __shfl_xor_sync(0xFFFFFFFF, val, offset);
return val;
}
// Split-KV scalar decode: one warp per query head, grid.z partitions KV.
__global__ void paged_attn_decode_split_kv_kernel(PagedAttentionParams<bf16> p) {
int batch = blockIdx.x / p.kv_head;
int kv_head = blockIdx.x % p.kv_head;
int split = blockIdx.z;
int group_size = blockDim.y;
int q_head = kv_head * group_size + threadIdx.y;
int lane = threadIdx.x;
int hd_per_thread = p.head_dim / 32;
float q_reg[8];
int q_off = ((batch * p.q_head + q_head) * 1) * p.head_dim + lane * hd_per_thread;
#pragma unroll
for (int i = 0; i < hd_per_thread; i++)
q_reg[i] = __bfloat162float(p.q[q_off + i]);
float m = -FLT_MAX, d = 0.0f, acc_reg[8] = {0.0f};
extern __shared__ __align__(16) bf16 k_smem[];
int chunks_total = (p.kv_len + PDC_CHUNK - 1) / PDC_CHUNK;
int chunks_per_split = (chunks_total + p.num_splits - 1) / p.num_splits;
int ch_begin = split * chunks_per_split;
int ch_end = min(chunks_total, ch_begin + chunks_per_split);
const int mask_base = batch * p.kv_len;
for (int ci = ch_begin; ci < ch_end; ci++) {
int chunk_start = ci * PDC_CHUNK;
int this_chunk = min(PDC_CHUNK, p.kv_len - chunk_start);
int total = this_chunk * p.head_dim;
for (int i = threadIdx.y * 32 + lane; i < total; i += blockDim.x * blockDim.y) {
int s = i / p.head_dim;
int d_dim = i % p.head_dim;
int pos = chunk_start + s;
int logical_page = pos / p.page_size;
int page_offset = pos % p.page_size;
int phys_page = p.page_table[batch * p.max_pages + logical_page];
if (phys_page >= 0) {
int64_t off = (int64_t)phys_page * p.page_size * p.kv_head * p.head_dim
+ (int64_t)page_offset * p.kv_head * p.head_dim
+ (int64_t)kv_head * p.head_dim
+ d_dim;
k_smem[i] = p.k_cache[off];
} else {
k_smem[i] = __float2bfloat16(0.0f);
}
}
__syncthreads();
for (int s = 0; s < this_chunk; s++) {
float partial = 0.0f;
#pragma unroll
for (int i = 0; i < hd_per_thread; i++)
partial += q_reg[i] * __bfloat162float(k_smem[s * p.head_dim + lane * hd_per_thread + i]);
partial = paged_warp_reduce_sum(partial) * p.scale;
if (p.use_mask && p.mask && !p.mask[mask_base + chunk_start + s])
partial = -FLT_MAX;
if (p.is_causal && (chunk_start + s) > p.causal_offset)
partial = -FLT_MAX;
float new_m = fmaxf(m, partial);
float alpha = expf(m - new_m);
float beta = expf(partial - new_m);
d = d * alpha + beta;
int pos = chunk_start + s;
int logical_page = pos / p.page_size;
int page_offset = pos % p.page_size;
int phys_page = p.page_table[batch * p.max_pages + logical_page];
if (phys_page >= 0) {
int64_t v_base = (int64_t)phys_page * p.page_size * p.kv_head * p.head_dim
+ (int64_t)page_offset * p.kv_head * p.head_dim
+ (int64_t)kv_head * p.head_dim;
#pragma unroll
for (int i = 0; i < hd_per_thread; i++)
acc_reg[i] = acc_reg[i] * alpha + __bfloat162float(p.v_cache[v_base + lane * hd_per_thread + i]) * beta;
} else {
#pragma unroll
for (int i = 0; i < hd_per_thread; i++)
acc_reg[i] = acc_reg[i] * alpha + 0.0f * beta;
}
m = new_m;
}
__syncthreads();
}
size_t bh = (size_t)batch * p.q_head + q_head;
size_t slot = bh * p.num_splits + split;
int d0 = lane * hd_per_thread;
#pragma unroll
for (int i = 0; i < hd_per_thread; i++)
p.o_part[slot * p.head_dim + (d0 + i)] = acc_reg[i];
if (lane == 0) {
p.ml_part[slot * 2] = m;
p.ml_part[slot * 2 + 1] = d;
}
}
__global__ void paged_attn_decode_combine_kernel(PagedAttentionParams<bf16> p) {
int bh = blockIdx.x;
int d = threadIdx.x;
if (d >= p.head_dim) return;
size_t split_base = (size_t)bh * p.num_splits;
const float* mlp = p.ml_part + split_base * 2;
const float* op = p.o_part + split_base * p.head_dim;
float m = -FLT_MAX, l = 0.0f, acc = 0.0f;
for (int s = 0; s < p.num_splits; s++) {
float mi = mlp[s * 2];
if (mi <= -FLT_MAX) continue;
float li = mlp[s * 2 + 1];
float nm = fmaxf(m, mi);
float corr = __expf(m - nm);
float e = __expf(mi - nm);
acc = acc * corr + op[s * p.head_dim + d] * e;
l = l * corr + li * e;
m = nm;
}
float inv = (l > 1e-20f) ? (1.0f / l) : 0.0f;
p.o[(size_t)bh * p.head_dim + d] = __float2bfloat16(acc * inv);
}
@@ -0,0 +1,182 @@
#pragma once
#include <cfloat>
#include <cuda_bf16.h>
#include "attn_common.h"
#include "attn_mma_utils.cuh"
using bf16 = __nv_bfloat16;
// Paged split-KV tensor-core decode via GQA head-packing.
// Identical algorithm to attn_decode_split_kv_mma_kernel but reads K/V
// directly from the page pool through a page table, eliminating the gather
// copy. Each tile (BC=32) fits within a single page (page_size >= 32), so
// the page-table lookup happens once per tile for cp.async.
//
// Optimizations mirror attn_decode_split_kv_mma_kernel:
// - Q loaded directly from global into mma A-operand registers (no sQ)
// - Double-buffered KV (STAGES=2) for D<=128, single-buffer for D=256
// - Predicated cp.async for unified full/partial tile path
template <int HEAD_DIM, int BC, int STAGES = (HEAD_DIM <= 128) ? 2 : 1>
__global__ void paged_attn_decode_split_kv_mma_kernel(PagedAttentionParams<bf16> p) {
constexpr int BR = 16;
constexpr int KD = HEAD_DIM / 16;
constexpr int NC8 = BC / 8;
constexpr int KT2 = BC / 16;
constexpr int DN8 = HEAD_DIM / 8;
constexpr int LD = HEAD_DIM;
constexpr int SWIZ_MASK = (HEAD_DIM >= 64) ? 7 : (HEAD_DIM / 8 - 1);
constexpr int VEC = 8;
constexpr int TOTAL = BC * HEAD_DIM;
const int lane = threadIdx.x;
const int gid = lane >> 2;
const int tid4 = lane & 3;
const int kv_head_idx = blockIdx.x;
const int batch = blockIdx.y;
const int split = blockIdx.z;
const int G = p.q_head / p.kv_head;
const int q_head0 = kv_head_idx * G;
__shared__ __align__(16) bf16 sK[STAGES * BC * LD];
__shared__ __align__(16) bf16 sV[STAGES * BC * LD];
// ---- Load Q directly from global into mma A-operand registers ----
const int q_base = (batch * p.q_head + q_head0) * HEAD_DIM;
const int qra = gid;
const int qrb = gid + 8;
const bool va = qra < G, vb = qrb < G;
unsigned Qa[KD][4];
#pragma unroll
for (int kt = 0; kt < KD; kt++) {
int c = kt * 16 + tid4 * 2;
const unsigned* pau = reinterpret_cast<const unsigned*>(
&p.q[q_base + qra * HEAD_DIM + c]);
const unsigned* pbu = reinterpret_cast<const unsigned*>(
&p.q[q_base + qrb * HEAD_DIM + c]);
Qa[kt][0] = va ? pau[0] : 0u;
Qa[kt][1] = vb ? pbu[0] : 0u;
Qa[kt][2] = va ? pau[4] : 0u;
Qa[kt][3] = vb ? pbu[4] : 0u;
}
float Oacc[DN8][4];
#pragma unroll
for (int j = 0; j < DN8; j++)
Oacc[j][0] = Oacc[j][1] = Oacc[j][2] = Oacc[j][3] = 0.0f;
float m0 = -FLT_MAX, m1 = -FLT_MAX, l0 = 0.0f, l1 = 0.0f;
const int mask_base = batch * p.kv_len;
const int tiles_total = (p.kv_len + BC - 1) / BC;
const int tiles_per_split = (tiles_total + p.num_splits - 1) / p.num_splits;
const int ti_begin = split * tiles_per_split;
const int ti_end = min(tiles_total, ti_begin + tiles_per_split);
const int has_mask = p.use_mask && p.mask;
// Paged strides (constant for the block)
const int64_t page_stride = (int64_t)p.page_size * p.kv_head * HEAD_DIM;
const int64_t pos_stride = (int64_t)p.kv_head * HEAD_DIM;
const int64_t head_off = (int64_t)kv_head_idx * HEAD_DIM;
// ---- Load tile lambda: predicated cp.async, paged addressing ----
auto load_tile = [&](int ti, int buf) {
int kv0 = ti * BC;
bf16* dK = sK + buf * BC * LD;
bf16* dV = sV + buf * BC * LD;
int logical_page = kv0 / p.page_size;
int phys_page = p.page_table[batch * p.max_pages + logical_page];
bool page_valid = (phys_page >= 0);
#pragma unroll
for (int i = lane * VEC; i < TOTAL; i += 32 * VEC) {
int r = i / HEAD_DIM, d = i % HEAD_DIM;
int kc = kv0 + r;
bool valid = (kc < p.kv_len) && page_valid;
int page_off = kc % p.page_size;
int64_t gmem_base = (int64_t)phys_page * page_stride
+ (int64_t)page_off * pos_stride
+ head_off;
int off = r * LD + swiz_col(d, r, SWIZ_MASK);
cp_async_16_pred(&dK[off], &p.k_cache[gmem_base + d], valid);
cp_async_16_pred(&dV[off], &p.v_cache[gmem_base + d], valid);
}
cp_async_commit();
};
// ---- Prologue: issue first tile load ----
if (ti_begin < ti_end) {
load_tile(ti_begin, 0);
}
for (int ti = ti_begin; ti < ti_end; ti++) {
constexpr int BUF_MASK = (STAGES > 1) ? (STAGES - 1) : 0;
int buf = (ti - ti_begin) & BUF_MASK;
cp_async_wait_group<0>();
__syncwarp();
if constexpr (STAGES > 1) {
if (ti + 1 < ti_end)
load_tile(ti + 1, (ti + 1 - ti_begin) & BUF_MASK);
}
const bf16* bK = sK + buf * BC * LD;
const bf16* bV = sV + buf * BC * LD;
int kv0 = ti * BC;
float Sacc[NC8][4];
mma_compute_scores<KD, NC8>(Qa, bK, LD, SWIZ_MASK, lane, Sacc);
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++)
Sacc[n8][0] *= p.scale, Sacc[n8][1] *= p.scale,
Sacc[n8][2] *= p.scale, Sacc[n8][3] *= p.scale;
int maxc = p.is_causal ? min(p.kv_len, p.causal_offset + 1) : p.kv_len;
mma_softmax_tile<NC8, DN8>(kv0, maxc, maxc,
mask_base, p.mask, has_mask,
Sacc, Oacc, m0, m1, l0, l1, lane);
mma_pv_accumulate<DN8, KT2>(Sacc, bV, LD, SWIZ_MASK, lane, Oacc);
__syncwarp();
if constexpr (STAGES == 1) {
if (ti + 1 < ti_end)
load_tile(ti + 1, 0);
}
}
// ---- write UN-normalised partials for this split ----
auto split_slot = [&](int h) -> size_t {
size_t bh = (size_t)batch * p.q_head + h;
return bh * p.num_splits + split;
};
#pragma unroll
for (int dn8 = 0; dn8 < DN8; dn8++) {
int d = dn8 * 8 + 2 * tid4;
int r0 = gid, r1 = gid + 8;
if (r0 < G) {
int h = q_head0 + r0;
float* op = p.o_part + split_slot(h) * HEAD_DIM;
op[d] = Oacc[dn8][0];
op[d + 1] = Oacc[dn8][1];
}
if (r1 < G) {
int h = q_head0 + r1;
float* op = p.o_part + split_slot(h) * HEAD_DIM;
op[d] = Oacc[dn8][2];
op[d + 1] = Oacc[dn8][3];
}
}
if (tid4 == 0) {
int r0 = gid, r1 = gid + 8;
if (r0 < G) {
int h = q_head0 + r0;
float* mp = p.ml_part + split_slot(h) * 2;
mp[0] = m0; mp[1] = l0;
}
if (r1 < G) {
int h = q_head0 + r1;
float* mp = p.ml_part + split_slot(h) * 2;
mp[0] = m1; mp[1] = l1;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
#include "attn_prefill_split_q.cuh"
#include "attn_entry_utils.cuh"
#ifndef ASTRAI_NO_MMA
#include "attn_prefill_split_q_mma.cuh"
#endif
template <int HEAD_DIM>
static void dispatch_prefill(AttentionParams<bf16>& p) {
#ifndef ASTRAI_NO_MMA
constexpr int WARPS = 4, BR = 16;
// KV tile: bigger tiles amortize the per-tile cp.async wait + barrier +
// loop overhead over more tensor-core work (this kernel is latency-bound,
// not compute/bandwidth-bound), so BC=32 wins ~6-8% over BC=16 for
// D<=128. D=256 stays at 16: BC=32 double-buffered would need 64KB smem,
// over the 48KB static cap.
constexpr int BC = (HEAD_DIM <= 128) ? 32 : 16;
dim3 grid((p.q_len + BR * WARPS - 1) / (BR * WARPS), p.q_head, p.batch);
dim3 block(WARPS * 32, 1, 1);
// Static shared memory — double-buffered K/V only (no sQ: Q goes direct
// to registers). 2*BC*LD bf16 each for sK and sV → 4*BC*HEAD_DIM*2 bytes.
// Occupancy is smem-capped: D=64→3 blocks/SM (16KB), D=128→1 (32KB),
// D=256→1 (32KB, BC=16).
attn_prefill_split_q_mma_kernel<HEAD_DIM, WARPS, BC><<<grid, block>>>(p);
#else
constexpr int G = 8, ROWS = 32, P_BC = 32;
dim3 grid((p.q_len + ROWS - 1) / ROWS, p.q_head, p.batch);
dim3 block(G, ROWS, 1);
attn_prefill_split_q_kernel_t<HEAD_DIM, G, ROWS, P_BC><<<grid, block>>>(p);
#endif
}
torch::Tensor attn_prefill(
torch::Tensor q,
torch::Tensor k,
torch::Tensor v,
c10::optional<torch::Tensor> mask,
bool is_causal = false,
int64_t causal_offset = 0,
c10::optional<double> scale = c10::nullopt
) {
AttentionParams<bf16> p;
attn_pack_params(q, k, v, mask, is_causal, causal_offset, scale, p);
TORCH_CHECK(p.head_dim % 16 == 0, "head_dim must be multiple of 16");
auto O = torch::empty_like(q);
p.o = (bf16*)O.data_ptr();
switch (p.head_dim) {
case 32:
dispatch_prefill<32>(p);
break;
case 64:
dispatch_prefill<64>(p);
break;
case 128:
dispatch_prefill<128>(p);
break;
case 256:
dispatch_prefill<256>(p);
break;
default:
TORCH_CHECK(false, "prefill: unsupported head_dim ", p.head_dim,
" (supported: 32,64,128,256)");
}
return O;
}
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
m.def("attn_prefill", &attn_prefill,
py::arg("q"),
py::arg("k"),
py::arg("v"),
py::arg("mask") = py::none(),
py::arg("is_causal") = false,
py::arg("causal_offset") = 0,
py::arg("scale") = py::none(),
"GQA prefill (tensor-core mma on sm_80+, scalar fallback)");
}
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <cfloat>
#include <cuda_bf16.h>
#include "attn_common.h"
using bf16 = __nv_bfloat16;
// v9: group-split register blocking. G threads cooperate on one query row,
// each owning HEAD_DIM/G dims of qreg[]/acc[]. Small per-thread footprint keeps
// occupancy high; the S dot product is reduced across the G-lane group with a
// short shuffle chain (log2(G) shuffles) instead of a full 32-lane warp reduce.
// Online (per-kv) softmax — cheap because acc[] is only HEAD_DIM/G long.
// Templated on <HEAD_DIM, G, ROWS, P_BC>. Block = (G, ROWS). G power-of-two,
// G*ROWS a multiple of 32 with groups warp-aligned.
template <int G>
__device__ __forceinline__ float group_reduce_sum(float v, unsigned mask) {
#pragma unroll
for (int o = G / 2; o > 0; o >>= 1)
v += __shfl_xor_sync(mask, v, o);
return v;
}
// load 8 contiguous bf16 from (16-byte aligned) smem as one float4, unpack to
// 8 floats — cuts shared-load instructions 8x vs scalar bf16 loads.
__device__ __forceinline__ void ld8(const bf16* p, float* o) {
float4 raw = *reinterpret_cast<const float4*>(p);
const __nv_bfloat162* h = reinterpret_cast<const __nv_bfloat162*>(&raw);
#pragma unroll
for (int j = 0; j < 4; j++) {
float2 f = __bfloat1622float2(h[j]);
o[2 * j] = f.x;
o[2 * j + 1] = f.y;
}
}
template <int HEAD_DIM, int G, int ROWS, int P_BC>
__global__ void attn_prefill_split_q_kernel_t(AttentionParams<bf16> p) {
constexpr int DPT = HEAD_DIM / G;
int q_tile = blockIdx.x;
int q_head = blockIdx.y;
int batch = blockIdx.z;
int gpos = threadIdx.x; // 0..G-1 (which d-chunk)
int row = threadIdx.y; // 0..ROWS-1
int q_row = q_tile * ROWS + row;
int kv_head = q_head / (p.q_head / p.kv_head);
__shared__ __align__(16) bf16 sK[P_BC * HEAD_DIM];
__shared__ __align__(16) bf16 sV[P_BC * HEAD_DIM];
float qreg[DPT];
if (q_row < p.q_len) {
int q_off = ((batch * p.q_head + q_head) * p.q_len + q_row) * HEAD_DIM + gpos * DPT;
#pragma unroll
for (int i = 0; i < DPT; i++)
qreg[i] = __bfloat162float(p.q[q_off + i]) * p.scale;
}
float m = -FLT_MAX, l = 0.0f;
float acc[DPT];
#pragma unroll
for (int i = 0; i < DPT; i++)
acc[i] = 0.0f;
int kv_base = ((batch * p.kv_head + kv_head) * p.kv_len) * HEAD_DIM;
int tiles = (p.kv_len + P_BC - 1) / P_BC;
int tt = G * ROWS;
int lid = row * G + gpos;
// per-group shuffle mask: only the G lanes of this row's group participate,
// so causal masking (differing loop bounds across rows in a warp) is safe.
int lane_in_warp = lid & 31;
unsigned gmask = (G == 32) ? 0xFFFFFFFFu
: (((1u << G) - 1u) << (lane_in_warp & ~(G - 1)));
for (int ti = 0; ti < tiles; ti++) {
int kv0 = ti * P_BC;
int tlen = min(P_BC, p.kv_len - kv0);
for (int i = lid; i < tlen * HEAD_DIM; i += tt) {
int gidx = kv_base + (kv0 + i / HEAD_DIM) * HEAD_DIM + (i % HEAD_DIM);
sK[i] = p.k[gidx];
sV[i] = p.v[gidx];
}
__syncthreads();
int lim = tlen;
if (p.is_causal && q_row < p.q_len) {
int ep = q_row + p.causal_offset + 1;
if (kv0 >= ep)
lim = 0;
else if (kv0 + tlen > ep)
lim = ep - kv0;
}
for (int s = 0; s < lim; s++) {
const bf16* kr = sK + s * HEAD_DIM + gpos * DPT;
float part = 0.0f;
#pragma unroll
for (int i = 0; i < DPT; i += 8) {
float k8[8];
ld8(kr + i, k8);
#pragma unroll
for (int j = 0; j < 8; j++)
part = fmaf(qreg[i + j], k8[j], part);
}
float dot = group_reduce_sum<G>(part, gmask);
if (p.use_mask && p.mask && !p.mask[batch * p.kv_len + kv0 + s])
dot = -FLT_MAX;
float nm = fmaxf(m, dot);
float al = __expf(m - nm);
float be = __expf(dot - nm);
l = l * al + be;
const bf16* vr = sV + s * HEAD_DIM + gpos * DPT;
#pragma unroll
for (int i = 0; i < DPT; i += 8) {
float v8[8];
ld8(vr + i, v8);
#pragma unroll
for (int j = 0; j < 8; j++)
acc[i + j] = fmaf(v8[j], be, acc[i + j] * al);
}
m = nm;
}
__syncthreads();
}
if (q_row < p.q_len) {
int o_off = ((batch * p.q_head + q_head) * p.q_len + q_row) * HEAD_DIM + gpos * DPT;
float rl = (l > 1e-10f) ? (1.0f / l) : 0.0f;
#pragma unroll
for (int i = 0; i < DPT; i++)
p.o[o_off + i] = __float2bfloat16(acc[i] * rl);
}
}
+204
View File
@@ -0,0 +1,204 @@
#pragma once
#include <cfloat>
#include <cuda_bf16.h>
#include "attn_common.h"
#include "attn_mma_utils.cuh"
using bf16 = __nv_bfloat16;
// Tensor-core prefill flash attention (raw mma.sync PTX).
// One warp owns BR=16 query rows. S = Q@K^T and O = P@V run on bf16 tensor
// cores via mma.sync.m16n8k16 (f32 accumulate). Q fragments are loaded once
// straight from global into the mma A-operand layout (no smem staging) and
// kept resident in registers across the tile loop. S, O, and the online-softmax
// stats (m, l) also live in registers.
// Shared memory is statically sized via template parameters — no dynamic
// allocation. The mma fragment layout is used directly: the S accumulator
// (f32) maps element-for-element onto the P matrix_a (bf16) operand, so
// softmax needs no shuffle repack; row reductions fold across the 4-lane
// thread group. Templated on <HEAD_DIM, WARPS, BC> with BC a multiple of 16.
//
// Software pipeline: K/V are double-buffered and loaded via cp.async one tile
// ahead, so the next tile streams from global memory while the current tile's
// tensor-core math runs — hiding load latency (long_scoreboard). A single
// __syncthreads per tile both publishes the freshly loaded tile cross-warp and
// (because it runs before the next prefetch) guards the buffer being refilled,
// so no second barrier is needed. Predicated cp.async (cp_async_16_pred)
// zero-fills rows past kv_len, unifying full and partial tiles on one path.
// BC=32 (D<=128) amortizes the per-tile wait+barrier+loop overhead over more
// tensor-core work — this kernel is latency-bound (low occupancy from high
// register pressure), so fewer, larger tiles beat many tiny ones.
//
// Optimizations: load Q fragments directly from global in mma A-operand layout
// (no sQ staging, no prologue barriers); post-multiply scale in float after
// S=Q@K^T to avoid bf16 precision loss; packed bf16x2 output stores;
// causal tile skipping (block-level prefetch bound + warp-level compute skip);
// XOR swizzle (swiz_col) → eliminates ldmatrix bank conflicts without LD
// padding (LD=HEAD_DIM).
template <int HEAD_DIM, int WARPS, int BC>
__global__ void attn_prefill_split_q_mma_kernel(AttentionParams<bf16> p) {
constexpr int BR = 16;
constexpr int KD = HEAD_DIM / 16; // Q/K k-tiles
constexpr int NC8 = BC / 8; // S n-tiles (N=8 each)
constexpr int KT2 = BC / 16; // P k-tiles (K=16 each)
constexpr int DN8 = HEAD_DIM / 8; // O n-tiles (N=8 each)
constexpr int LD = HEAD_DIM; // XOR swizzle (swiz_col) handles bank conflicts
constexpr int SWIZ_MASK = (HEAD_DIM >= 64) ? 7 : (HEAD_DIM / 8 - 1); // chunk bits, stay within LD
const int warp = threadIdx.x / 32;
const int lane = threadIdx.x % 32;
const int gid = lane >> 2; // 0..7 → rows gid, gid+8
const int tid4 = lane & 3; // 0..3
const int nthreads = WARPS * 32;
const int q_head = blockIdx.y;
const int batch = blockIdx.z;
const int kv_head = q_head / (p.q_head / p.kv_head);
const int qrow0 = (blockIdx.x * WARPS + warp) * BR;
// Static shared memory — sized by template parameters at compile time.
// K/V are double-buffered (STAGES=2): the next tile's cp.async load runs
// while the current tile's tensor-core math executes, hiding global-load
// latency (FA2-style software pipeline). No dynamic smem / carveout opt-in.
constexpr int STAGES = 2;
__shared__ __align__(16) bf16 sK[STAGES * BC * LD];
__shared__ __align__(16) bf16 sV[STAGES * BC * LD];
// Load the Q fragments straight from global into the mma A-operand layout
// (m16n8k16, row-major): no sQ staging area and no serialized per-warp
// prologue barriers. Each lane reads exactly the 8 Q elements ldmatrix
// would have produced, pre-scaled by the attention scale. Kept resident in
// registers across the tile loop.
// frag[0]/[2]: row = qrow0 + gid ; frag[1]/[3]: row = qrow0 + gid + 8
// frag[0]/[1]: cols kt*16 + tid4*2 + {0,1} ; frag[2]/[3]: + 8
const int q_base = ((batch * p.q_head + q_head) * p.q_len) * HEAD_DIM;
const int qra = qrow0 + gid;
const int qrb = qrow0 + gid + 8;
const bool va = qra < p.q_len, vb = qrb < p.q_len;
unsigned Qa[KD][4];
#pragma unroll
for (int kt = 0; kt < KD; kt++) {
int c = kt * 16 + tid4 * 2;
const unsigned* pau = reinterpret_cast<const unsigned*>(
&p.q[q_base + qra * HEAD_DIM + c]);
const unsigned* pbu = reinterpret_cast<const unsigned*>(
&p.q[q_base + qrb * HEAD_DIM + c]);
Qa[kt][0] = va ? pau[0] : 0u;
Qa[kt][1] = vb ? pbu[0] : 0u;
Qa[kt][2] = va ? pau[4] : 0u;
Qa[kt][3] = vb ? pbu[4] : 0u;
}
float Oacc[DN8][4];
#pragma unroll
for (int j = 0; j < DN8; j++)
Oacc[j][0] = Oacc[j][1] = Oacc[j][2] = Oacc[j][3] = 0.0f;
float m0 = -FLT_MAX, m1 = -FLT_MAX, l0 = 0.0f, l1 = 0.0f;
const int kv_base = ((batch * p.kv_head + kv_head) * p.kv_len) * HEAD_DIM;
const int tiles = (p.kv_len + BC - 1) / BC;
const int qr0 = qrow0 + gid; // row for c0/c1
const int qr1 = qrow0 + gid + 8; // row for c2/c3
// Causal tile-skip bounds (no-op when is_causal == 0)
const int use_skip = p.is_causal;
const int max_kv = qrow0 + BR - 1 + p.causal_offset;
const int block_max_kv =
blockIdx.x * WARPS * BR + WARPS * BR - 1 + p.causal_offset;
const int has_mask = p.use_mask && p.mask;
const int mb = batch * p.kv_len;
// Last active tile: block-level causal bound (all warps in the block share
// the K/V load, so the prefetch range is the block max, not per-warp).
int t_end = tiles - 1;
if (use_skip) {
int bt = block_max_kv / BC;
if (bt < t_end) t_end = bt;
}
constexpr int VEC = 8; // bf16 per cp.async unit (16 bytes)
constexpr int TOTAL = BC * HEAD_DIM;
// Issue cp.async loads for tile `ti` into shared buffer `buf`. Predicated
// loads zero-fill rows past kv_len, so partial tiles need no scalar path.
auto load_tile = [&](int ti, int buf) {
int kv0 = ti * BC;
bf16* dK = sK + buf * BC * LD;
bf16* dV = sV + buf * BC * LD;
#pragma unroll
for (int i = threadIdx.x * VEC; i < TOTAL; i += nthreads * VEC) {
int r = i / HEAD_DIM, d = i % HEAD_DIM;
int kc = kv0 + r;
bool valid = kc < p.kv_len;
int off = r * LD + swiz_col(d, r, SWIZ_MASK);
cp_async_16_pred(&dK[off], &p.k[kv_base + kc * HEAD_DIM + d], valid);
cp_async_16_pred(&dV[off], &p.v[kv_base + kc * HEAD_DIM + d], valid);
}
cp_async_commit();
};
// Prologue: kick off the first tile's load.
load_tile(0, 0);
for (int ti = 0; ti <= t_end; ti++) {
int buf = ti & 1;
// Wait for the current tile's async copies, then a single barrier: it
// both publishes this tile's data cross-warp AND guarantees the prior
// compute on the buffer we are about to refill has finished. Issuing
// the next tile's load *after* this barrier lets one barrier cover both
// hazards (vs two), while the load still overlaps this tile's math.
cp_async_wait_group<0>();
__syncthreads();
if (ti < t_end) load_tile(ti + 1, (ti + 1) & 1);
const bf16* bK = sK + buf * BC * LD;
const bf16* bV = sV + buf * BC * LD;
int kv0 = ti * BC;
// Warp-level causal skip
if (!use_skip || kv0 <= max_kv) {
// S = Q @ K^T + scale + online softmax + O += P @ V
float Sacc[NC8][4];
mma_compute_scores<KD, NC8>(Qa, bK, LD, SWIZ_MASK, lane, Sacc);
// post-multiply scale in float (no bf16 precision loss from pre-scaling Q)
#pragma unroll
for (int n8 = 0; n8 < NC8; n8++)
Sacc[n8][0] *= p.scale, Sacc[n8][1] *= p.scale,
Sacc[n8][2] *= p.scale, Sacc[n8][3] *= p.scale;
int maxc0 = p.is_causal ? min(p.kv_len, qr0 + p.causal_offset + 1)
: p.kv_len;
int maxc1 = p.is_causal ? min(p.kv_len, qr1 + p.causal_offset + 1)
: p.kv_len;
mma_softmax_tile<NC8, DN8>(kv0, maxc0, maxc1,
mb, p.mask, has_mask,
Sacc, Oacc, m0, m1, l0, l1, lane);
mma_pv_accumulate<DN8, KT2>(Sacc, bV, LD, SWIZ_MASK, lane, Oacc);
} // if active (warp-level causal skip)
}
// ---- write output ---- (packed bf16x2 stores: one 32-bit STG per pair,
// halves store count and removes the uncoalesced scalar-store penalty)
float rl0 = (l0 > 1e-20f) ? (1.0f / l0) : 0.0f;
float rl1 = (l1 > 1e-20f) ? (1.0f / l1) : 0.0f;
const int o_base = ((batch * p.q_head + q_head) * p.q_len) * HEAD_DIM;
#pragma unroll
for (int dn8 = 0; dn8 < DN8; dn8++) {
int d = dn8 * 8 + 2 * tid4;
if (qr0 < p.q_len) {
__nv_bfloat162 v = __floats2bfloat162_rn(Oacc[dn8][0] * rl0,
Oacc[dn8][1] * rl0);
*reinterpret_cast<__nv_bfloat162*>(&p.o[o_base + qr0 * HEAD_DIM + d]) = v;
}
if (qr1 < p.q_len) {
__nv_bfloat162 v = __floats2bfloat162_rn(Oacc[dn8][2] * rl1,
Oacc[dn8][3] * rl1);
*reinterpret_cast<__nv_bfloat162*>(&p.o[o_base + qr1 * HEAD_DIM + d]) = v;
}
}
}
+199
View File
@@ -0,0 +1,199 @@
/*
Pure-C test:
nvcc -I csrc -arch=sm_89 -O3 \
--use_fast_math --ptxas-options=-O3 --extra-device-vectorization \
csrc/tests/attn_decode_test.cu -o test && ./test
*/
#include "test_utils.cuh"
#include "../kernels/attn_decode_split_kv.cuh"
#ifndef ASTRAI_NO_MMA
#include "../kernels/attn_decode_split_kv_mma.cuh"
#endif
// Split-K scratch (torch-free): the production launcher allocates these from
// torch; here we pass pre-allocated device buffers so the bench loop doesn't
// pay a cudaMalloc per iteration. Size for the maximum split count (32).
struct DecodeScratch {
float* o_part = nullptr;
float* ml_part = nullptr;
};
// Launch the production decode path (tensor-core head-packing MMA on sm_80+,
// scalar fallback otherwise), mirroring dispatch_decode() in attn_decode.cu.
#ifndef ASTRAI_NO_MMA
static bool decode_use_mma(const AttentionParams<bf16>& p) {
int G = p.q_head / p.kv_head;
return !p.use_mask && G > 1 && G <= 16;
}
template <int HEAD_DIM, int BC, int STAGES = (HEAD_DIM <= 128) ? 2 : 1>
static void launch_mma_decode(AttentionParams<bf16>& p, DecodeScratch& sc) {
int tiles_total = (p.kv_len + BC - 1) / BC;
p.num_splits = compute_num_splits(p.batch * p.kv_head, tiles_total);
p.o_part = sc.o_part;
p.ml_part = sc.ml_part;
attn_decode_split_kv_mma_kernel<HEAD_DIM, BC, STAGES>
<<<dim3(p.kv_head, p.batch, p.num_splits), 32>>>(p);
attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
#endif
static void launch_scalar_decode(AttentionParams<bf16>& p, DecodeScratch& sc) {
int gs = p.q_head / p.kv_head;
int chunks_total = (p.kv_len + DC_CHUNK - 1) / DC_CHUNK;
p.num_splits = compute_num_splits(p.batch * p.kv_head, chunks_total);
p.o_part = sc.o_part;
p.ml_part = sc.ml_part;
size_t smem = DC_CHUNK * p.head_dim * sizeof(bf16);
attn_decode_split_kv_kernel<<<dim3(p.batch * p.kv_head, 1, p.num_splits), dim3(32, gs), smem>>>(p);
attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
template <int HEAD_DIM>
static void dispatch_decode_t(AttentionParams<bf16>& p, DecodeScratch& sc) {
#ifndef ASTRAI_NO_MMA
if (decode_use_mma(p)) { launch_mma_decode<HEAD_DIM, 32>(p, sc); return; }
#endif
launch_scalar_decode(p, sc);
}
static void dispatch_decode(AttentionParams<bf16>& p, DecodeScratch& sc) {
dispatch_by_head_dim(p.head_dim, [&]<int D>() { dispatch_decode_t<D>(p, sc); });
}
// Warmed-up, CUDA-event timed sweep over the production decode MMA path.
static void bench() {
const int cfgs[][5] = {
{1, 32, 4, 512, 128}, // B, Hq, Hk, kv_len, D
{1, 32, 4, 1024, 128},
{1, 32, 4, 2048, 128},
{1, 32, 4, 4096, 128},
{16, 32, 4, 2048, 128},
{32, 32, 4, 1024, 128},
};
const int WARMUP = 10, ITERS = 100;
printf("\n===== DECODE BENCH (warmup=%d iters=%d) =====\n", WARMUP, ITERS);
print_bench_header();
for (int ci = 0; ci < 6; ci++) {
int B = cfgs[ci][0], Hq = cfgs[ci][1], Hk = cfgs[ci][2];
int sl = cfgs[ci][3], D = cfgs[ci][4];
size_t nQ = (size_t)B * Hq * D;
size_t nKV = (size_t)B * Hk * sl * D;
bf16 *dQ, *dK, *dV, *dO;
cudaMalloc(&dQ, nQ*2); cudaMalloc(&dK, nKV*2);
cudaMalloc(&dV, nKV*2); cudaMalloc(&dO, nQ*2);
size_t big = nQ > nKV ? nQ : nKV; bf16* tmp = new bf16[big];
for (size_t i = 0; i < nQ; i++) tmp[i] = f2bf(randf());
cudaMemcpy(dQ, tmp, nQ*2, cudaMemcpyHostToDevice);
for (size_t i = 0; i < nKV; i++) tmp[i] = f2bf(randf());
cudaMemcpy(dK, tmp, nKV*2, cudaMemcpyHostToDevice);
for (size_t i = 0; i < nKV; i++) tmp[i] = f2bf(randf());
cudaMemcpy(dV, tmp, nKV*2, cudaMemcpyHostToDevice);
delete[] tmp;
AttentionParams<bf16> p;
p.batch = B; p.q_head = Hq; p.kv_head = Hk; p.q_len = 1; p.kv_len = sl;
p.head_dim = D; p.use_mask = 0; p.is_causal = 0; p.causal_offset = 0;
p.scale = 1.0f / sqrtf((float)D);
p.q = dQ; p.k = dK; p.v = dV; p.mask = nullptr; p.o = dO;
DecodeScratch sc;
cudaMalloc(&sc.o_part, (size_t)B*Hq*32*D*sizeof(float));
cudaMalloc(&sc.ml_part, (size_t)B*Hq*32*2*sizeof(float));
auto launch = [&]() { dispatch_decode(p, sc); };
double flops = 4.0 * B * Hq * (double)sl * D;
double bytes = 2.0 * (2.0 * nKV * sizeof(bf16));
BenchResult r = bench_kernel(launch, WARMUP, ITERS, flops, bytes);
char cfg[64];
snprintf(cfg, sizeof(cfg),
"B=%2d Hq=%2d Hk=%d q=%4d kv=%4d D=%3d causal=%d",
B, Hq, Hk, 1, sl, D, 0);
print_bench_row(cfg, r);
cudaFree(dQ); cudaFree(dK); cudaFree(dV); cudaFree(dO);
cudaFree(sc.o_part); cudaFree(sc.ml_part);
}
}
int main() {
const int configs[][5] = {
{1, 2, 1, 64, 32}, // B,Hq,Hk,seq_len,D
{1, 32, 4, 512, 128},
{1, 32, 4, 1024, 128},
};
int n_cfgs = sizeof(configs) / sizeof(configs[0]);
for (int ci = 0; ci < n_cfgs; ci++) {
int B = configs[ci][0], Hq = configs[ci][1], Hk = configs[ci][2];
int sl = configs[ci][3], D = configs[ci][4], gs = Hq / Hk;
printf("=== B=%d Hq=%d Hk=%d seq=%d D=%d gs=%d ===\n", B,Hq,Hk,sl,D,gs);
size_t nQ = B*Hq*1*D, nKV = B*Hk*sl*D;
float *hQ=new float[nQ], *hK=new float[nKV], *hV=new float[nKV];
for (size_t i=0;i<nQ;i++) hQ[i]=randf();
for (size_t i=0;i<nKV;i++){hK[i]=randf();hV[i]=randf();}
bool* hMask=new bool[B*sl];
for (int i=0;i<B*sl;i++) hMask[i]=true;
bf16 *dQ,*dK,*dV,*dO,*tmp;
bool* dMask;
cudaMalloc(&dQ,nQ*2); cudaMalloc(&dK,nKV*2);
cudaMalloc(&dV,nKV*2); cudaMalloc(&dO,nQ*2);
cudaMalloc(&dMask,B*sl);
tmp=new bf16[max(nQ,nKV)];
for (size_t i=0;i<nQ;i++) tmp[i]=f2bf(hQ[i]);
cudaMemcpy(dQ,tmp,nQ*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(hK[i]);
cudaMemcpy(dK,tmp,nKV*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(hV[i]);
cudaMemcpy(dV,tmp,nKV*2,cudaMemcpyHostToDevice);
cudaMemcpy(dMask,hMask,B*sl,cudaMemcpyHostToDevice);
AttentionParams<bf16> p;
p.batch=B; p.q_head=Hq; p.kv_head=Hk; p.q_len=1; p.kv_len=sl; p.head_dim=D;
p.use_mask=0; p.is_causal=0; p.causal_offset=0;
p.scale=1.0f/sqrtf((float)D);
p.q=dQ; p.k=dK; p.v=dV; p.mask=nullptr; p.o=dO;
// Split-K scratch (max 32 splits), sized for the production MMA path.
DecodeScratch sc;
cudaMalloc(&sc.o_part, (size_t)B*Hq*32*D*sizeof(float));
cudaMalloc(&sc.ml_part, (size_t)B*Hq*32*2*sizeof(float));
double t0=now_ms();
dispatch_decode(p, sc);
cudaDeviceSynchronize();
double kms=now_ms()-t0;
cudaError_t err=cudaGetLastError();
if (err!=cudaSuccess){printf("CUDA err: %s\n",cudaGetErrorString(err));return 1;}
bf16* hOut=new bf16[nQ];
cudaMemcpy(hOut,dO,nQ*2,cudaMemcpyDeviceToHost);
float* ref=new float[nQ];
cpu_attention_ref(hQ, hK, hV, hMask, ref, B, Hq, Hk, 1, sl, D, 0, 0);
float max_err=0;
for (size_t i=0;i<nQ;i++){
float d=fabsf(bf2f(hOut[i])-ref[i]);
if(d>max_err) max_err=d;
}
printf("kernel: %.3f ms max_err: %.6e\n\n",kms,max_err);
cudaFree(dQ);cudaFree(dK);cudaFree(dV);cudaFree(dO);cudaFree(dMask);
cudaFree(sc.o_part);cudaFree(sc.ml_part);
delete[]hQ;delete[]hK;delete[]hV;delete[]hMask;delete[]hOut;delete[]ref;delete[]tmp;
}
printf("All tests passed!\n");
bench();
return 0;
}
+330
View File
@@ -0,0 +1,330 @@
// Compile:
// nvcc -I csrc -arch=sm_89 -O3 --use_fast_math --ptxas-options=-O3 \
// --extra-device-vectorization csrc/tests/attn_paged_decode_test.cu \
// -o /tmp/test_paged && /tmp/test_paged
#include <cstring>
#include "test_utils.cuh"
#include "../kernels/attn_paged_decode_split_kv.cuh"
#ifndef ASTRAI_NO_MMA
#include "../kernels/attn_paged_decode_split_kv_mma.cuh"
#endif
// Copy contiguous K/V from page pool (reference gather)
static void gather_kv_cpu(
const bf16* h_k_pool, const bf16* h_v_pool,
const int64_t* h_pt, int B, int Hkv, int kv_len,
int page_size, int head_dim,
bf16* h_k, bf16* h_v)
{
int max_pages = (kv_len + page_size - 1) / page_size;
size_t page_stride = (size_t)page_size * Hkv * head_dim;
for (int b = 0; b < B; b++) {
for (int pos = 0; pos < kv_len; pos++) {
int log_pg = pos / page_size;
int pg_off = pos % page_size;
int phys = (int)h_pt[b * max_pages + log_pg];
for (int h = 0; h < Hkv; h++) {
size_t src_base = (size_t)phys * page_stride
+ (size_t)pg_off * Hkv * head_dim
+ h * head_dim;
size_t dst_base = ((size_t)b * Hkv + h) * kv_len * head_dim + (size_t)pos * head_dim;
memcpy(h_k + dst_base, h_k_pool + src_base, head_dim * sizeof(bf16));
memcpy(h_v + dst_base, h_v_pool + src_base, head_dim * sizeof(bf16));
}
}
}
}
template <int HEAD_DIM>
static void launch_paged_decode(PagedAttentionParams<bf16, float>& p) {
#ifndef ASTRAI_NO_MMA
int G_check = p.q_head / p.kv_head;
bool use_mma = !p.use_mask && G_check >= 1 && G_check <= 16 && p.page_size >= 32;
if (use_mma) {
constexpr int STAGES = (HEAD_DIM <= 128) ? 2 : 1;
int tiles_total = (p.kv_len + 32 - 1) / 32;
p.num_splits = compute_num_splits(p.batch * p.kv_head, tiles_total);
paged_attn_decode_split_kv_mma_kernel<HEAD_DIM, 32, STAGES>
<<<dim3(p.kv_head, p.batch, p.num_splits), 32>>>(p);
} else
#endif
{
int group_sz = p.q_head / p.kv_head;
int chunks_total = (p.kv_len + PDC_CHUNK - 1) / PDC_CHUNK;
p.num_splits = compute_num_splits(p.batch * p.kv_head, chunks_total);
size_t smem = PDC_CHUNK * p.head_dim * sizeof(bf16);
paged_attn_decode_split_kv_kernel<<<
dim3(p.batch * p.kv_head, 1, p.num_splits),
dim3(32, group_sz), smem>>>(p);
}
paged_attn_decode_combine_kernel<<<p.batch * p.q_head, p.head_dim>>>(p);
}
template <int HEAD_DIM>
static int run_test(int B, int Hq, int Hkv, int kv_len, int page_size, int seed) {
printf("B=%d Hq=%d Hkv=%d kv_len=%d page_sz=%d head_dim=%d ... ", B, Hq, Hkv, kv_len, page_size, HEAD_DIM);
fflush(stdout);
int max_pages = (kv_len + page_size - 1) / page_size;
int n_phys_pages = B * max_pages;
size_t sz_q = (size_t)B * Hq * 1 * HEAD_DIM * sizeof(bf16);
size_t sz_o = sz_q;
size_t sz_kv = (size_t)n_phys_pages * page_size * Hkv * HEAD_DIM * sizeof(bf16);
size_t sz_pt = (size_t)B * max_pages * sizeof(int64_t);
int max_splits = 32;
size_t sz_op = (size_t)B * Hq * max_splits * HEAD_DIM * sizeof(float);
size_t sz_ml = (size_t)B * Hq * max_splits * 2 * sizeof(float);
bf16 *d_q, *d_o_paged, *d_o_ref;
bf16 *d_k_pool, *d_v_pool;
int64_t* d_pt;
float *d_op, *d_ml;
cudaMalloc(&d_q, sz_q);
cudaMalloc(&d_o_paged, sz_o);
cudaMalloc(&d_o_ref, sz_o);
cudaMalloc(&d_k_pool, sz_kv);
cudaMalloc(&d_v_pool, sz_kv);
cudaMalloc(&d_pt, sz_pt);
cudaMalloc(&d_op, sz_op);
cudaMalloc(&d_ml, sz_ml);
srand(seed);
auto rnd = [&]() { return (rand() / (float)RAND_MAX) * 2.0f - 1.0f; };
bf16* h_q = (bf16*)malloc(sz_q);
for (int i = 0; i < B * Hq * HEAD_DIM; i++)
h_q[i] = __float2bfloat16(rnd());
cudaMemcpy(d_q, h_q, sz_q, cudaMemcpyHostToDevice);
bf16* h_k_pool = (bf16*)malloc(sz_kv);
bf16* h_v_pool = (bf16*)malloc(sz_kv);
size_t ps = (size_t)page_size * Hkv * HEAD_DIM;
for (int pg = 0; pg < n_phys_pages; pg++) {
for (int off = 0; off < page_size; off++) {
for (int h = 0; h < Hkv; h++) {
for (int d = 0; d < HEAD_DIM; d++) {
float v = sinf((float)(pg * 7919 + off * 1049 + h * 331 + d));
size_t idx = (size_t)pg * ps + (size_t)off * Hkv * HEAD_DIM + h * HEAD_DIM + d;
h_k_pool[idx] = __float2bfloat16(v);
h_v_pool[idx] = __float2bfloat16(v * 0.3f);
}
}
}
}
cudaMemcpy(d_k_pool, h_k_pool, sz_kv, cudaMemcpyHostToDevice);
cudaMemcpy(d_v_pool, h_v_pool, sz_kv, cudaMemcpyHostToDevice);
int64_t* h_pt = (int64_t*)malloc(sz_pt);
int next_pg = 0;
for (int b = 0; b < B; b++)
for (int p = 0; p < max_pages; p++)
h_pt[b * max_pages + p] = next_pg++;
cudaMemcpy(d_pt, h_pt, sz_pt, cudaMemcpyHostToDevice);
bf16* h_k_cont = (bf16*)malloc((size_t)B * kv_len * Hkv * HEAD_DIM * sizeof(bf16));
bf16* h_v_cont = (bf16*)malloc((size_t)B * kv_len * Hkv * HEAD_DIM * sizeof(bf16));
gather_kv_cpu(h_k_pool, h_v_pool, h_pt, B, Hkv, kv_len, page_size, HEAD_DIM, h_k_cont, h_v_cont);
float* h_q_f = (float*)malloc((size_t)B * Hq * HEAD_DIM * sizeof(float));
float* h_k_f = (float*)malloc((size_t)B * kv_len * Hkv * HEAD_DIM * sizeof(float));
float* h_v_f = (float*)malloc((size_t)B * kv_len * Hkv * HEAD_DIM * sizeof(float));
for (int i = 0; i < B * Hq * HEAD_DIM; i++) h_q_f[i] = bf2f(h_q[i]);
for (int i = 0; i < B * kv_len * Hkv * HEAD_DIM; i++) {
h_k_f[i] = bf2f(h_k_cont[i]);
h_v_f[i] = bf2f(h_v_cont[i]);
}
float* h_o_ref = (float*)calloc(B * Hq * HEAD_DIM, sizeof(float));
cpu_attention_ref(h_q_f, h_k_f, h_v_f, nullptr, h_o_ref, B, Hq, Hkv, 1, kv_len, HEAD_DIM, 0, 0);
float scale_val = 1.0f / sqrtf((float)HEAD_DIM);
PagedAttentionParams<bf16, float> p;
p.batch = B; p.q_head = Hq; p.kv_head = Hkv; p.q_len = 1;
p.kv_len = kv_len; p.head_dim = HEAD_DIM;
p.use_mask = 0; p.is_causal = 0; p.causal_offset = 0;
p.num_splits = 1; p.scale = scale_val;
p.page_size = page_size; p.max_pages = max_pages;
p.page_table = d_pt;
p.k_cache = d_k_pool; p.v_cache = d_v_pool;
p.q = d_q; p.mask = nullptr; p.o = d_o_paged;
p.o_part = d_op; p.ml_part = d_ml;
launch_paged_decode<HEAD_DIM>(p);
cudaDeviceSynchronize();
bf16* h_o_bf16 = (bf16*)malloc(sz_o);
cudaMemcpy(h_o_bf16, d_o_paged, sz_o, cudaMemcpyDeviceToHost);
float* h_o_paged = (float*)malloc(B * Hq * HEAD_DIM * sizeof(float));
for (int i = 0; i < B * Hq * HEAD_DIM; i++)
h_o_paged[i] = __bfloat162float(h_o_bf16[i]);
float max_err = 0.0f;
int bad_idx = -1;
for (int i = 0; i < B * Hq * HEAD_DIM; i++) {
float e = fabsf(h_o_paged[i] - h_o_ref[i]);
if (e > max_err) { max_err = e; bad_idx = i; }
}
bool pass = max_err < 0.02f;
if (pass) {
printf("PASS (max_abs_err=%.4e)\n", max_err);
} else {
int b = bad_idx / (Hq * HEAD_DIM);
int h = (bad_idx / HEAD_DIM) % Hq;
int d = bad_idx % HEAD_DIM;
printf("FAIL (max_abs_err=%.4e at [%d,%d,%d]: ref=%.4f got=%.4f)\n",
max_err, b, h, d, h_o_ref[bad_idx], h_o_paged[bad_idx]);
printf(" ref[0..7]:");
for (int i = 0; i < 8 && i < HEAD_DIM; i++)
printf(" %.4f", h_o_ref[i]);
printf("\n got[0..7]:");
for (int i = 0; i < 8 && i < HEAD_DIM; i++)
printf(" %.4f", h_o_paged[i]);
printf("\n");
}
free(h_q); free(h_k_pool); free(h_v_pool); free(h_pt);
free(h_k_cont); free(h_v_cont);
free(h_q_f); free(h_k_f); free(h_v_f);
free(h_o_ref); free(h_o_bf16); free(h_o_paged);
cudaFree(d_q); cudaFree(d_o_paged); cudaFree(d_o_ref);
cudaFree(d_k_pool); cudaFree(d_v_pool); cudaFree(d_pt);
cudaFree(d_op); cudaFree(d_ml);
return pass ? 0 : 1;
}
struct TestCase {
int head_dim;
int B, Hq, Hkv, kv_len, page_size, seed;
};
static const TestCase TESTS[] = {
{128, 1, 1, 1, 8, 128, 1},
{128, 1, 4, 4, 128, 128, 2},
{128, 2, 4, 4, 256, 128, 3},
{128, 1, 4, 1, 64, 64, 4},
{128, 1, 8, 2, 64, 128, 5},
{128, 2, 16, 4, 128, 128, 6},
{64, 1, 4, 2, 32, 128, 7},
{256, 1, 2, 1, 16, 128, 8},
{32, 1, 4, 2, 32, 64, 9},
{128, 3, 8, 2, 256, 128, 10},
{128, 2, 32, 8, 512, 128, 11},
#ifndef ASTRAI_NO_MMA
{128, 1, 16, 2, 256, 128, 12},
{128, 2, 32, 4, 512, 128, 13},
#endif
};
static int dispatch_test(const TestCase& tc) {
bool matched = false;
int r = 0;
dispatch_by_head_dim(tc.head_dim, [&]<int D>() {
matched = true;
r = run_test<D>(tc.B, tc.Hq, tc.Hkv, tc.kv_len, tc.page_size, tc.seed);
});
return matched ? r : 1;
}
// Warmed-up, CUDA-event timed sweep over paged decode configs.
// Bytes = K + V read through page table (B*Hk*kv*D each), bf16.
template <int HEAD_DIM>
static void bench_config(int B, int Hq, int Hkv, int kv_len, int page_size) {
int max_pages = (kv_len + page_size - 1) / page_size;
int n_phys_pages = B * max_pages;
size_t sz_q = (size_t)B * Hq * 1 * HEAD_DIM * sizeof(bf16);
size_t sz_kv = (size_t)n_phys_pages * page_size * Hkv * HEAD_DIM * sizeof(bf16);
size_t sz_pt = (size_t)B * max_pages * sizeof(int64_t);
int max_splits = 32;
size_t sz_op = (size_t)B * Hq * max_splits * HEAD_DIM * sizeof(float);
size_t sz_ml = (size_t)B * Hq * max_splits * 2 * sizeof(float);
bf16 *d_q, *d_o, *d_k_pool, *d_v_pool;
int64_t* d_pt;
float *d_op, *d_ml;
cudaMalloc(&d_q, sz_q); cudaMalloc(&d_o, sz_q);
cudaMalloc(&d_k_pool, sz_kv); cudaMalloc(&d_v_pool, sz_kv);
cudaMalloc(&d_pt, sz_pt);
cudaMalloc(&d_op, sz_op); cudaMalloc(&d_ml, sz_ml);
bf16* tmp = (bf16*)malloc(sz_kv > sz_q ? sz_kv : sz_q);
for (size_t i = 0; i < sz_q / sizeof(bf16); i++) tmp[i] = f2bf(randf());
cudaMemcpy(d_q, tmp, sz_q, cudaMemcpyHostToDevice);
for (size_t i = 0; i < sz_kv / sizeof(bf16); i++) tmp[i] = f2bf(randf());
cudaMemcpy(d_k_pool, tmp, sz_kv, cudaMemcpyHostToDevice);
cudaMemcpy(d_v_pool, tmp, sz_kv, cudaMemcpyHostToDevice);
int64_t* h_pt = (int64_t*)malloc(sz_pt);
int next_pg = 0;
for (int b = 0; b < B; b++)
for (int p = 0; p < max_pages; p++)
h_pt[b * max_pages + p] = next_pg++;
cudaMemcpy(d_pt, h_pt, sz_pt, cudaMemcpyHostToDevice);
free(h_pt);
float scale_val = 1.0f / sqrtf((float)HEAD_DIM);
PagedAttentionParams<bf16, float> pa;
pa.batch = B; pa.q_head = Hq; pa.kv_head = Hkv; pa.q_len = 1;
pa.kv_len = kv_len; pa.head_dim = HEAD_DIM;
pa.use_mask = 0; pa.is_causal = 0; pa.causal_offset = 0;
pa.num_splits = 1; pa.scale = scale_val;
pa.page_size = page_size; pa.max_pages = max_pages;
pa.page_table = d_pt;
pa.k_cache = d_k_pool; pa.v_cache = d_v_pool;
pa.q = d_q; pa.mask = nullptr; pa.o = d_o;
pa.o_part = d_op; pa.ml_part = d_ml;
const int WARMUP = 10, ITERS = 100;
auto launch = [&]() { launch_paged_decode<HEAD_DIM>(pa); };
double flops = 4.0 * B * Hq * (double)kv_len * HEAD_DIM;
size_t nKV = (size_t)B * Hkv * kv_len * HEAD_DIM;
double bytes = 2.0 * (2.0 * nKV * sizeof(bf16));
BenchResult r = bench_kernel(launch, WARMUP, ITERS, flops, bytes);
char cfg[64];
snprintf(cfg, sizeof(cfg),
"B=%2d Hq=%2d Hk=%d q=%4d kv=%4d D=%3d page=%3d",
B, Hq, Hkv, 1, kv_len, HEAD_DIM, page_size);
print_bench_row(cfg, r);
free(tmp);
cudaFree(d_q); cudaFree(d_o);
cudaFree(d_k_pool); cudaFree(d_v_pool); cudaFree(d_pt);
cudaFree(d_op); cudaFree(d_ml);
}
static void bench() {
printf("\n===== PAGED DECODE BENCH =====\n");
print_bench_header();
bench_config<128>(1, 32, 4, 512, 128);
bench_config<128>(1, 32, 4, 1024, 128);
bench_config<128>(1, 32, 4, 2048, 128);
bench_config<128>(1, 32, 4, 4096, 128);
bench_config<128>(16, 32, 4, 2048, 128);
bench_config<128>(32, 32, 4, 1024, 128);
}
int main() {
int n = sizeof(TESTS) / sizeof(TESTS[0]);
int fail = 0;
printf("=== Paged Decode vs CPU reference (%d cases) ===\n\n", n);
for (int i = 0; i < n; i++) {
fail += dispatch_test(TESTS[i]);
if (fail) break;
}
if (fail) {
printf("\nFAILED (%d/%d tests failed)\n", fail, n);
return fail;
}
printf("\nAll %d tests passed!\n", n);
bench();
return 0;
}
+176
View File
@@ -0,0 +1,176 @@
/*
Pure-C test:
nvcc -I csrc -arch=sm_89 -O3 \
--use_fast_math --ptxas-options=-O3 --extra-device-vectorization \
csrc/tests/attn_prefill_test.cu -o test && ./test
*/
#include "test_utils.cuh"
#include "../kernels/attn_prefill_split_q.cuh"
#ifndef ASTRAI_NO_MMA
#include "../kernels/attn_prefill_split_q_mma.cuh"
#endif
// Launch the production prefill path (tensor-core MMA on sm_80+, else the
// scalar fallback), mirroring dispatch_prefill() in attn_prefill.cu.
template <int HEAD_DIM>
static void launch_prefill(AttentionParams<bf16>& p) {
#ifndef ASTRAI_NO_MMA
constexpr int WARPS = 4, BR = 16;
constexpr int BC = (HEAD_DIM <= 128) ? 32 : 16;
dim3 grid((p.q_len + BR * WARPS - 1) / (BR * WARPS), p.q_head, p.batch);
dim3 block(WARPS * 32, 1, 1);
attn_prefill_split_q_mma_kernel<HEAD_DIM, WARPS, BC><<<grid, block>>>(p);
#else
constexpr int G = 8, ROWS = 32, P_BC = 32;
dim3 grid((p.q_len + ROWS - 1) / ROWS, p.q_head, p.batch);
dim3 block(G, ROWS, 1);
attn_prefill_split_q_kernel_t<HEAD_DIM, G, ROWS, P_BC><<<grid, block>>>(p);
#endif
}
static void dispatch_prefill(AttentionParams<bf16>& p) {
switch (p.head_dim) {
case 64: launch_prefill<64>(p); break;
case 128: launch_prefill<128>(p); break;
default: printf("bench: unsupported D=%d\n", p.head_dim);
}
}
// Warmed-up, CUDA-event timed throughput sweep over the production MMA path.
// Reports per-call latency and effective tensor-core TFLOP/s (2 matmuls:
// QK^T and P@V, each 2*B*Hq*ql*kl*D flops; halved for causal).
static void bench() {
const int cfgs[][7] = {
{1,32,4,512,512,128,0},
{1,32,4,1024,1024,128,0},
{1,32,4,2048,2048,128,0},
{1,32,4,2048,2048,128,1},
{4,32,4,2048,2048,128,1},
{1,32,4,4096,4096,128,1},
};
int n = sizeof(cfgs)/sizeof(cfgs[0]);
const int WARMUP = 10, ITERS = 50;
printf("\n===== PREFILL BENCH (warmup=%d iters=%d) =====\n", WARMUP, ITERS);
printf("%-46s | %10s | %10s | %10s\n",
"config", "latency", "bandwidth", "throughput");
printf("---------------------------------------------------------------"
"----------------------------\n");
for (int ci = 0; ci < n; ci++) {
int B=cfgs[ci][0], Hq=cfgs[ci][1], Hk=cfgs[ci][2];
int ql=cfgs[ci][3], kl=cfgs[ci][4], D=cfgs[ci][5], causal=cfgs[ci][6];
size_t nQ=(size_t)B*Hq*ql*D, nKV=(size_t)B*Hk*kl*D;
bf16 *dQ,*dK,*dV,*dO,*tmp;
cudaMalloc(&dQ,nQ*2); cudaMalloc(&dK,nKV*2);
cudaMalloc(&dV,nKV*2); cudaMalloc(&dO,nQ*2);
size_t big = nQ>nKV?nQ:nKV; tmp=new bf16[big];
for (size_t i=0;i<nQ;i++) tmp[i]=f2bf(randf());
cudaMemcpy(dQ,tmp,nQ*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(randf());
cudaMemcpy(dK,tmp,nKV*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(randf());
cudaMemcpy(dV,tmp,nKV*2,cudaMemcpyHostToDevice);
AttentionParams<bf16> p;
p.batch=B; p.q_head=Hq; p.kv_head=Hk; p.q_len=ql; p.kv_len=kl; p.head_dim=D;
p.use_mask=0; p.is_causal=causal; p.causal_offset=0;
p.scale=1.0f/sqrtf((float)D);
p.q=dQ; p.k=dK; p.v=dV; p.mask=nullptr; p.o=dO;
for (int i=0;i<WARMUP;i++) dispatch_prefill(p);
cudaDeviceSynchronize();
cudaError_t err=cudaGetLastError();
if (err!=cudaSuccess){printf("CUDA err: %s\n",cudaGetErrorString(err));return;}
cudaEvent_t s,e; cudaEventCreate(&s); cudaEventCreate(&e);
cudaEventRecord(s);
for (int i=0;i<ITERS;i++) dispatch_prefill(p);
cudaEventRecord(e); cudaEventSynchronize(e);
float ms=0; cudaEventElapsedTime(&ms,s,e); ms/=ITERS;
double flops = 4.0*B*Hq*(double)ql*kl*D;
if (causal) flops *= 0.5;
double tflops = flops/(ms*1e-3)/1e12;
// HBM traffic: Q + O (B*Hq*ql*D each) + K + V (B*Hk*kl*D each), bf16.
double bytes = 2.0 * (2.0*nQ + 2.0*nKV);
double gbps = bytes/(ms*1e-3)/1e9;
char cfg[64];
snprintf(cfg, sizeof(cfg),
"B=%2d Hq=%2d Hk=%d q=%4d kv=%4d D=%3d causal=%d",
B,Hq,Hk,ql,kl,D,causal);
printf("%-46s | %7.4f ms | %7.1f GB/s | %6.2f TFLOP/s\n",
cfg, ms, gbps, tflops);
cudaFree(dQ);cudaFree(dK);cudaFree(dV);cudaFree(dO);
delete[]tmp; cudaEventDestroy(s); cudaEventDestroy(e);
}
}
int main() {
const int configs[][7] = {
{1,2,1,64,128,64,0}, // tiny: B,Hq,Hk,q,kv,D,causal
{1,32,4,512,512,128,0}, // standard
{1,32,4,128,256,128,0}, // medium
{1,4,2,256,256,128,1}, // causal
};
int n_configs = sizeof(configs) / sizeof(configs[0]);
for (int ci = 0; ci < n_configs; ci++) {
int B=configs[ci][0], Hq=configs[ci][1], Hk=configs[ci][2];
int ql=configs[ci][3], kl=configs[ci][4], D=configs[ci][5];
int causal=configs[ci][6];
printf("=== B=%d Hq=%d Hk=%d q=%d kv=%d D=%d causal=%d ===\n",
B,Hq,Hk,ql,kl,D,causal);
size_t nQ = B*Hq*ql*D, nKV = B*Hk*kl*D;
float *hQ=new float[nQ], *hK=new float[nKV], *hV=new float[nKV];
for (size_t i=0;i<nQ;i++) hQ[i]=randf();
for (size_t i=0;i<nKV;i++){hK[i]=randf();hV[i]=randf();}
bf16 *dQ,*dK,*dV,*dO,*tmp;
cudaMalloc(&dQ,nQ*2); cudaMalloc(&dK,nKV*2);
cudaMalloc(&dV,nKV*2); cudaMalloc(&dO,nQ*2);
tmp=new bf16[max(nQ,nKV)];
for (size_t i=0;i<nQ;i++) tmp[i]=f2bf(hQ[i]);
cudaMemcpy(dQ,tmp,nQ*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(hK[i]);
cudaMemcpy(dK,tmp,nKV*2,cudaMemcpyHostToDevice);
for (size_t i=0;i<nKV;i++) tmp[i]=f2bf(hV[i]);
cudaMemcpy(dV,tmp,nKV*2,cudaMemcpyHostToDevice);
AttentionParams<bf16> p;
p.batch=B; p.q_head=Hq; p.kv_head=Hk; p.q_len=ql; p.kv_len=kl; p.head_dim=D;
p.use_mask=0; p.is_causal=causal; p.causal_offset=0;
p.scale=1.0f/sqrtf((float)D);
p.q=dQ; p.k=dK; p.v=dV; p.mask=nullptr; p.o=dO;
double t0=now_ms();
dispatch_prefill(p);
cudaDeviceSynchronize();
double kms=now_ms()-t0;
cudaError_t err=cudaGetLastError();
if (err!=cudaSuccess){printf("CUDA err: %s\n",cudaGetErrorString(err));return 1;}
bf16* hOut=new bf16[nQ];
cudaMemcpy(hOut,dO,nQ*2,cudaMemcpyDeviceToHost);
float* ref=new float[nQ];
cpu_attention_ref(hQ, hK, hV, nullptr, ref, B, Hq, Hk, ql, kl, D, causal, 0);
float max_err=0;
for (size_t i=0;i<nQ;i++) {
float d=fabsf(bf2f(hOut[i])-ref[i]);
if(d>max_err) max_err=d;
}
printf("kernel: %.3f ms max_err: %.6e\n\n",kms,max_err);
cudaFree(dQ);cudaFree(dK);cudaFree(dV);cudaFree(dO);
delete[]hQ;delete[]hK;delete[]hV;delete[]hOut;delete[]ref;delete[]tmp;
}
printf("All tests passed!\n");
bench();
return 0;
}
+154
View File
@@ -0,0 +1,154 @@
#pragma once
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <chrono>
#include <cuda_bf16.h>
using bf16 = __nv_bfloat16;
inline bf16 f2bf(float x) { return __float2bfloat16(x); }
inline float bf2f(bf16 x) { return __bfloat162float(x); }
inline float randf() { return (float)rand() / (float)RAND_MAX - 0.5f; }
inline double now_ms() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
inline int compute_num_splits(int base_blocks, int tiles_total) {
int sm_count = 0;
cudaDeviceGetAttribute(&sm_count, cudaDevAttrMultiProcessorCount, 0);
int n = (2 * sm_count + base_blocks - 1) / base_blocks;
if (n > tiles_total) n = tiles_total;
if (n > 32) n = 32;
if (n < 1) n = 1;
return n;
}
#define CUDA_CHECK(call) \
do { \
cudaError_t _e = (call); \
if (_e != cudaSuccess) { \
printf("CUDA error %s at %s:%d\n", cudaGetErrorString(_e), __FILE__, __LINE__); \
exit(1); \
} \
} while (0)
struct BenchResult {
float ms;
double gbps;
double tflops;
};
template <typename Fn>
BenchResult bench_kernel(Fn launch, int warmup, int iters,
double flops, double bytes) {
for (int i = 0; i < warmup; i++) launch();
cudaDeviceSynchronize();
cudaError_t err = cudaGetLastError();
if (err != cudaSuccess) {
printf("CUDA error before bench: %s\n", cudaGetErrorString(err));
return {0, 0, 0};
}
cudaEvent_t s, e;
cudaEventCreate(&s); cudaEventCreate(&e);
cudaEventRecord(s);
for (int i = 0; i < iters; i++) launch();
cudaEventRecord(e); cudaEventSynchronize(e);
float ms = 0; cudaEventElapsedTime(&ms, s, e); ms /= iters;
cudaEventDestroy(s); cudaEventDestroy(e);
return {ms, bytes / (ms * 1e-3) / 1e9, flops / (ms * 1e-3) / 1e12};
}
inline void print_bench_header() {
printf("%-46s | %10s | %10s | %10s\n",
"config", "latency", "bandwidth", "throughput");
printf("---------------------------------------------------------------"
"----------------------------\n");
}
inline void print_bench_row(const char* cfg, const BenchResult& r) {
printf("%-46s | %7.4f ms | %7.1f GB/s | %6.2f TFLOP/s\n",
cfg, r.ms, r.gbps, r.tflops);
}
template <int... Ds>
struct _HeadSwitch;
template <int D>
struct _HeadSwitch<D> {
template <typename Fn>
static void call(int hd, Fn&& fn) { if (hd == D) fn.template operator()<D>(); }
};
template <int D, int... Rest>
struct _HeadSwitch<D, Rest...> {
template <typename Fn>
static void call(int hd, Fn&& fn) {
if (hd == D) fn.template operator()<D>();
else _HeadSwitch<Rest...>::call(hd, fn);
}
};
// Default set: 32, 64, 128, 256
template <typename Fn>
void dispatch_by_head_dim(int head_dim, Fn&& fn) {
_HeadSwitch<32, 64, 128, 256>::call(head_dim, fn);
}
// Generic CPU reference for multi-query / grouped-query attention.
// Tensor shapes (all float*):
// Q : [B, Hq, q_len, D]
// K : [B, Hk, kv_len, D]
// V : [B, Hk, kv_len, D]
// O : [B, Hq, q_len, D]
// mask: if q_len == 1, shape is [B, kv_len]; otherwise mask is not supported.
static void cpu_attention_ref(
const float* Q, const float* K, const float* V, const bool* mask,
float* O, int B, int Hq, int Hk, int q_len, int kv_len, int D,
int is_causal, int causal_offset
) {
float scale = 1.0f / sqrtf((float)D);
int n_rep = Hq / Hk;
for (int b = 0; b < B; b++) {
for (int h = 0; h < Hq; h++) {
int kv_h = h / n_rep;
for (int qi = 0; qi < q_len; qi++) {
float mv = -INFINITY, sv = 0.0f;
float accum[256] = {0.0f};
int lim = kv_len;
if (is_causal) {
int c = qi + causal_offset + 1;
lim = (c < kv_len) ? c : kv_len;
}
for (int kj = 0; kj < lim; kj++) {
if (mask != nullptr && q_len == 1) {
if (!mask[b * kv_len + kj]) continue;
}
float dot = 0.0f;
size_t q_idx = ((size_t)b * Hq + h) * q_len + qi;
size_t kv_idx = ((size_t)b * Hk + kv_h) * kv_len + kj;
for (int d = 0; d < D; d++)
dot += Q[q_idx * D + d] * K[kv_idx * D + d];
dot *= scale;
float nm = fmaxf(mv, dot);
float a = expf(mv - nm);
float b_exp = expf(dot - nm);
sv = sv * a + b_exp;
for (int d = 0; d < D; d++)
accum[d] = accum[d] * a + V[kv_idx * D + d] * b_exp;
mv = nm;
}
float inv = 1.0f / sv;
size_t o_idx = ((size_t)b * Hq + h) * q_len + qi;
for (int d = 0; d < D; d++)
O[o_idx * D + d] = accum[d] * inv;
}
}
}
}
+3 -3
View File
@@ -9,8 +9,8 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"h5py==3.15.1",
"numpy==2.3.2",
"torch==2.7.1",
"numpy==2.4.4",
"torch==2.11.0",
"tokenizers==0.21.4",
"tqdm==4.67.1",
"safetensors==0.5.3",
@@ -37,7 +37,7 @@ dev = ["pytest==9.0.2", "ruff"]
where = ["."]
[tool.pip]
extra-index-url = "https://download.pytorch.org/whl/cu126"
extra-index-url = "https://download.pytorch.org/whl/cu128"
[tool.setuptools.dynamic]
version = { attr = "astrai.__version__" }
+53 -12
View File
@@ -1,3 +1,4 @@
from argparse import ArgumentParser
from pathlib import Path
import torch
@@ -7,42 +8,82 @@ from astrai.model import AutoModel
from astrai.tokenize import AutoTokenizer
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PARAMETER_ROOT = Path(PROJECT_ROOT, "params")
def parse_args():
parser = ArgumentParser(description="Interactive streaming chat")
parser.add_argument(
"--model_path",
type=Path,
default=PROJECT_ROOT / "params",
help="Path to model weights (params/ or checkpoint/epoch_N_step_M/)",
)
parser.add_argument(
"--temperature",
type=float,
default=0.8,
help="Sampling temperature (default: 0.8)",
)
parser.add_argument(
"--top_p",
type=float,
default=0.95,
help="Top-p sampling threshold",
)
parser.add_argument(
"--top_k",
type=int,
default=50,
help="Top-k sampling threshold",
)
parser.add_argument(
"--max_tokens",
type=int,
default=2048,
help="Maximum tokens to generate",
)
parser.add_argument(
"--system_prompt",
type=str,
default="You are a helpful assistant.",
help="Optional system prompt",
)
return parser.parse_args()
def chat():
model = AutoModel.from_pretrained(PARAMETER_ROOT)
tokenizer = AutoTokenizer.from_pretrained(PARAMETER_ROOT)
model.to(device="cuda", dtype=torch.bfloat16)
args = parse_args()
model_path = args.model_path
messages = [{"role": "system", "content": "You are a helpful assistant."}]
model = AutoModel.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
model.to(device="cuda", dtype=torch.bfloat16)
engine = InferenceEngine(model=model, tokenizer=tokenizer)
messages = [{"role": "system", "content": args.system_prompt}]
while True:
query = input(">> ")
if query == "!exit":
break
# Add user message
messages.append({"role": "user", "content": query})
# Generate response
full_response = ""
prompt = tokenizer.apply_chat_template(messages, tokenize=False)
for token in engine.generate(
prompt=prompt,
stream=True,
max_tokens=2048,
temperature=0.8,
top_p=0.95,
top_k=50,
max_tokens=args.max_tokens,
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
):
print(token, end="", flush=True)
full_response += token
print()
# Add assistant response to messages
messages.append({"role": "assistant", "content": full_response.strip()})
+307
View File
@@ -0,0 +1,307 @@
"""SVD effective rank & weight statistics analysis for model checkpoints."""
import argparse
import json
from pathlib import Path
import safetensors.torch
import torch
def effective_rank_metrics(w: torch.Tensor) -> dict:
if w.ndim == 1:
return {"shape": tuple(w.shape), "is_1d": True}
w = w.float()
s = torch.linalg.svdvals(w)
s_sq = s**2
total = s_sq.sum()
cumsum = torch.cumsum(s_sq, dim=0) / total
min_dim = min(w.shape[0], w.shape[1])
er_90 = (cumsum < 0.90).sum().item() + 1
er_95 = (cumsum < 0.95).sum().item() + 1
er_99 = (cumsum < 0.99).sum().item() + 1
p = s_sq / total
p = p[p > 1e-30]
entropy = -(p * torch.log(p)).sum()
entropic_rank = torch.exp(entropy).item()
return {
"shape": tuple(w.shape),
"min_dim": min_dim,
"er_90": er_90,
"er_95": er_95,
"er_99": er_99,
"er_99_norm": er_99 / min_dim,
"er_95_norm": er_95 / min_dim,
"entropic_rank": entropic_rank,
"entropic_rank_norm": entropic_rank / min_dim,
"top1_ratio": s[0].item() / s.sum().item(),
"top5_ratio": s[:5].sum().item() / s.sum().item(),
"decay_ratio": s[-1].item() / s[0].item(),
"condition_number": s[0].item() / s[-1].item(),
"mean": w.mean().item(),
"std": w.std().item(),
"min": w.min().item(),
"max": w.max().item(),
}
def format_header(headers: list[str], widths: list[int]) -> str:
return "".join(h.ljust(w) for h, w in zip(headers, widths))
def format_row(values: list[str], widths: list[int]) -> str:
return "".join(v.ljust(w) for v, w in zip(values, widths))
def group_by_component(results: dict[str, dict]) -> dict[str, list[dict]]:
groups: dict[str, list[dict]] = {}
for key, r in results.items():
parts = key.split(".")
if parts[0] == "layers" and len(parts) >= 3:
sub = parts[2:]
if sub[0] == "attention":
comp = f"attn.{sub[1]}"
elif sub[0] == "mlp":
comp = f"mlp.{sub[1]}"
elif sub[0] == "input_norm":
comp = "input_norm"
elif sub[0] == "post_attention_norm":
comp = "post_attn_norm"
else:
comp = ".".join(sub)
else:
comp = key
groups.setdefault(comp, []).append(r)
return groups
def print_component_summary(results: dict[str, dict], title: str):
groups = group_by_component(results)
matrix_groups = {
k: [v for v in vs if not v.get("is_1d")]
for k, vs in groups.items()
if any(not v.get("is_1d") for v in vs)
}
widths = [20, 12, 12, 12, 12, 12]
print(f"\n{title}")
print(
format_header(
["Component", "N", "ER@99%", "EntRank%", "Top1 σ(%)", "Cond. Num"], widths
)
)
print("-" * sum(widths))
for name in sorted(matrix_groups.keys()):
items = matrix_groups[name]
n = len(items)
print(
format_row(
[
name,
str(n),
f"{sum(r['er_99_norm'] for r in items) / n:.4f}",
f"{sum(r['entropic_rank_norm'] for r in items) / n:.4f}",
f"{sum(r['top1_ratio'] for r in items) / n:.4f}",
f"{sum(r['condition_number'] for r in items) / n:.1f}",
],
widths,
)
)
all_er = [
r["er_99_norm"]
for vs in matrix_groups.values()
for r in vs
if "_norm" not in r or not r.get("is_1d")
]
if all_er:
m = sum(all_er) / len(all_er)
print(f"\n Overall Mean ER@99: {m:.4f} ({m * 100:.1f}% of dimension)")
if m > 0.85:
print(" → HIGH utilization: model near capacity → need more params")
elif m > 0.5:
print(" → MODERATE utilization: some headroom left")
else:
print(" → LOW utilization: significant unused capacity")
def print_layer_grid(results: dict[str, dict]):
comps = [
"attn.q_proj",
"attn.k_proj",
"attn.v_proj",
"attn.o_proj",
"mlp.up",
"mlp.gate",
"mlp.down",
]
widths = [6] + [10] * len(comps)
metric = "er_99_norm"
print(f"\n--- Per-Layer Effective Rank (99% energy) ---")
print(format_header(["Layer"] + comps, widths))
print("-" * sum(widths))
layer_data: dict[int, dict[str, dict]] = {}
for key, r in results.items():
parts = key.split(".")
if parts[0] != "layers":
continue
li = int(parts[1])
sub = parts[2:]
if sub[0] == "attention":
cname = f"attn.{sub[1]}"
elif sub[0] == "mlp":
cname = f"mlp.{sub[1]}"
else:
continue
layer_data.setdefault(li, {})[cname] = r
for li in sorted(layer_data):
values = [str(li)]
for c in comps:
v = layer_data[li].get(c, {}).get(metric, 0)
values.append(f"{v:.4f}")
print(format_row(values, widths))
def print_weight_stats(results: dict[str, dict]):
groups = group_by_component(results)
widths = [20, 12, 12, 12, 12]
print(f"\n--- Weight Value Statistics ---")
print(format_header(["Component", "Mean", "Std", "Min", "Max"], widths))
print("-" * sum(widths))
for name in sorted(groups.keys()):
items = groups[name]
means = [r.get("mean", 0) for r in items]
stds = [r.get("std", 0) for r in items]
mins = [r.get("min", 0) for r in items]
maxs = [r.get("max", 0) for r in items]
g_mean = sum(means) / len(means)
g_std = sum(stds) / len(stds)
g_min = min(mins)
g_max = max(maxs)
print(
format_row(
[
name,
f"{g_mean:.6f}",
f"{g_std:.6f}",
f"{g_min:.6f}",
f"{g_max:.6f}",
],
widths,
)
)
def print_params_summary(results: dict[str, dict]):
total_2d = sum(
r["shape"][0] * r["shape"][1] for r in results.values() if not r.get("is_1d")
)
total_1d = sum(r["shape"][0] for r in results.values() if r.get("is_1d"))
print(f"\n Total 2D params: {total_2d:,}")
print(f" Total 1D params: {total_1d:,}")
print(f" Total params: {total_2d + total_1d:,}")
def main():
parser = argparse.ArgumentParser(
description="SVD effective rank & weight statistics of a model checkpoint."
)
parser.add_argument(
"--ckpt_dir",
type=str,
required=True,
help="Path to checkpoint directory (containing model.safetensors + config.json).",
)
parser.add_argument(
"--compare",
type=str,
nargs="*",
help="Additional checkpoint directories to compare against.",
)
parser.add_argument(
"--no_svd",
action="store_true",
help="Skip SVD analysis, only show weight statistics (mean/std/min/max).",
)
args = parser.parse_args()
def analyze_one(ckpt_dir: str, label: str):
ckpt_dir = Path(ckpt_dir)
weights_path = ckpt_dir / "model.safetensors"
if not weights_path.exists():
print(f"ERROR: {weights_path} not found")
return {}
meta = {}
meta_path = ckpt_dir / "meta.json"
if meta_path.exists():
with open(meta_path) as f:
meta = json.load(f)
print(f"\n{'=' * 70}")
print(f" {label}: {ckpt_dir}")
if meta:
print(
f" Iteration: {meta.get('iteration', '?')}, "
f"Strategy: {meta.get('strategy', '?')}, "
f"nprocs={meta.get('nprocs', '?')}"
)
print(f"{'=' * 70}")
print(f"Loading weights...")
sd = safetensors.torch.load_file(str(weights_path))
print(f" {len(sd)} keys loaded")
weight_keys = [
k
for k in sd
if ".weight" in k and "rotary_embedding" not in k and "freqs_cis" not in k
]
results = {}
if not args.no_svd:
print(f"Computing SVD on {len(weight_keys)} tensors...")
for i, k in enumerate(sorted(weight_keys)):
print(f" [{i + 1}/{len(weight_keys)}] {k:<60s}", end="\r")
results[k] = effective_rank_metrics(sd[k])
print()
else:
print(f"Computing stats on {len(weight_keys)} tensors (no SVD)...")
for i, k in enumerate(sorted(weight_keys)):
t = sd[k]
results[k] = {
"shape": tuple(t.shape),
"is_1d": t.ndim == 1,
"mean": t.float().mean().item(),
"std": t.float().std().item(),
"min": t.float().min().item(),
"max": t.float().max().item(),
}
print_params_summary(results)
if not args.no_svd:
print_component_summary(
results, "\n=== SVD Effective Rank by Component ==="
)
print_layer_grid(results)
print_weight_stats(results)
return results
analyze_one(args.ckpt_dir, "Primary")
if args.compare:
for cdir in args.compare:
analyze_one(cdir, "Compare")
if __name__ == "__main__":
main()
+271 -212
View File
@@ -1,22 +1,22 @@
"""HumanEval code generation benchmark.
"""HumanEval benchmark — functional pipeline design.
Generates n completions per problem, extracts function bodies, executes
against hidden tests, and computes pass@k.
Pipeline:
load -> generate -> extract -> test -> score -> report
Usage::
python scripts/tools/evaluate_humaneval.py --param_path ./params \
--data_path HumanEval.jsonl.gz --output results.json \
--num_samples 200 --temperature 0.8 --max_tokens 512
Each stage is a pure function (except GPU/CPU-bound I/O stages).
Config is a single dataclass; side effects are isolated at pipeline boundaries.
"""
import argparse
import itertools
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from math import prod
from multiprocessing import Process, Queue
from typing import Dict, List, Optional, Tuple
from typing import Dict, Iterator, List, Optional, Sequence, Tuple
import numpy as np
import torch
@@ -26,11 +26,15 @@ from astrai.inference import InferenceEngine
from astrai.model import AutoModel
from astrai.tokenize import AutoTokenizer
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HUMANEVAL_URL = (
"https://github.com/openai/human-eval/raw/master/data/HumanEval.jsonl.gz"
)
_STOP_SEQUENCES = [
STOP_SEQUENCES = [
"\nclass ",
"\ndef ",
"\n# ",
@@ -40,43 +44,85 @@ _STOP_SEQUENCES = [
]
def _download_humaneval(data_path: str):
if os.path.exists(data_path):
@dataclass
class EvalConfig:
param_path: str = "./params"
data_path: str = "./humaneval/HumanEval.jsonl"
output: Optional[str] = None
test_only: Optional[str] = None
generate_only: bool = False
num_samples: int = 200
max_tokens: int = 512
temperature: float = 0.8
top_p: float = 0.95
top_k: int = 50
batch_size: int = 32
test_timeout: float = 3.0
test_workers: int = 8
k_values: Tuple[int, ...] = (1, 10, 100)
problem_indices: Optional[List[int]] = None
def download(url: str, path: str):
if os.path.exists(path):
return
import gzip
import urllib.request
os.makedirs(os.path.dirname(data_path) or ".", exist_ok=True)
print(f"Downloading HumanEval from {HUMANEVAL_URL} ...")
tmp = data_path + ".tmp"
urllib.request.urlretrieve(HUMANEVAL_URL, tmp)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
print(f"Downloading {url} ...")
tmp = path + ".tmp"
urllib.request.urlretrieve(url, tmp)
with gzip.open(tmp, "rb") as f_in:
with open(data_path, "wb") as f_out:
with open(path, "wb") as f_out:
f_out.write(f_in.read())
os.remove(tmp)
print(f" saved to {data_path}")
print(f" saved to {path}")
def _load_problems(data_path: str) -> List[dict]:
problems = []
with open(data_path, "r", encoding="utf-8") as f:
def load_jsonl(path: str) -> List[dict]:
rows = []
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
problems.append(json.loads(line))
return problems
rows.append(json.loads(line))
return rows
def _extract_function_body(code: str, entry_point: str) -> Optional[str]:
"""Extract the function body from a completion."""
def save_json(path: str, data):
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def create_engine(param_path: str, batch_size: int) -> InferenceEngine:
model = AutoModel.from_pretrained(param_path)
tokenizer = AutoTokenizer.from_pretrained(param_path)
model.to(device="cuda", dtype=torch.bfloat16)
return InferenceEngine(
model=model,
tokenizer=tokenizer,
max_batch_size=batch_size,
)
def trim_stop(text: str) -> str:
for stop in STOP_SEQUENCES:
idx = text.find(stop)
if idx != -1:
text = text[:idx]
return text
def extract_body(code: str, entry_point: str) -> Optional[str]:
pattern = rf"def\s+{re.escape(entry_point)}\b[^:]*:"
match = re.search(pattern, code)
if not match:
# Use the full code as-is if we can't find the function
return code
body_start = match.end()
lines = code[body_start:].split("\n")
lines = code[match.end() :].split("\n")
body_lines = []
started = False
@@ -94,240 +140,253 @@ def _extract_function_body(code: str, entry_point: str) -> Optional[str]:
body_lines.append(stripped)
body = "\n".join(body_lines)
if not body.strip():
return None
return body
return body if body.strip() else None
def _trim_stop_sequences(text: str) -> str:
for stop in _STOP_SEQUENCES:
idx = text.find(stop)
if idx != -1:
text = text[:idx]
return text
def _execute_code(problem: dict, completion: str, timeout: float = 3.0) -> bool:
"""Run the completion against hidden tests in a subprocess."""
def _worker(queue, full_code):
try:
namespace = {}
exec(full_code, namespace)
check = namespace.get("check")
if check is None:
queue.put(False)
return
check(namespace.get(problem["entry_point"]))
queue.put(True)
except Exception:
queue.put(False)
full_code = problem["prompt"] + completion + "\n" + problem["test"]
queue: Queue = Queue()
proc = Process(target=_worker, args=(queue, full_code))
proc.start()
proc.join(timeout)
if proc.is_alive():
proc.terminate()
proc.join()
return False
try:
return queue.get_nowait()
except Exception:
return False
def _pass_at_k(n: int, c: int, k: int) -> float:
"""Unbiased estimator of pass@k."""
if n - c < k:
return 1.0
return 1.0 - float(prod(1.0 - k / np.arange(n - c + 1, n + 1)))
def _deduplicate(completions: List[str]) -> List[str]:
def deduplicate(seq: Sequence[str]) -> List[str]:
seen = set()
unique = []
for c in completions:
if c not in seen:
seen.add(c)
unique.append(c)
return unique
return [x for x in seq if not (x in seen or seen.add(x))]
def _generate(
def generate_batch(
engine: InferenceEngine,
prompt: str,
num_samples: int,
n: int,
batch_size: int,
max_tokens: int,
temperature: float,
top_p: float,
top_k: int,
batch_size: int,
) -> List[str]:
batches = [prompt] * min(batch_size, num_samples)
completions = []
remaining = num_samples
remaining = n
while remaining > 0:
current = min(batch_size, remaining)
batch_prompts = batches[:current]
outputs = engine.generate(
prompt=batch_prompts,
prompt=[prompt] * current,
stream=False,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
top_k=top_k,
)
if isinstance(outputs, str):
outputs = [outputs]
completions.extend(outputs)
completions.extend(outputs if isinstance(outputs, list) else [outputs])
remaining -= current
return _deduplicate(completions)
return deduplicate(completions)
def evaluate(
engine: InferenceEngine,
problems: List[dict],
num_samples: int,
max_tokens: int,
temperature: float,
top_p: float,
top_k: int,
batch_size: int,
k_values: Tuple[int, ...] = (1, 10, 100),
) -> Dict:
results = {}
all_pass_at_k = {k: [] for k in k_values}
for problem in tqdm.tqdm(problems, desc="HumanEval", unit="problem"):
task_id = problem["task_id"]
prompt = problem["prompt"]
entry_point = problem["entry_point"]
raw_completions = _generate(
engine,
prompt,
num_samples,
max_tokens,
temperature,
top_p,
top_k,
batch_size,
)
completions = []
for raw in raw_completions:
trimmed = _trim_stop_sequences(raw)
body = _extract_function_body(trimmed, entry_point)
def extract_completions(
raw: Sequence[str],
entry_point: str,
) -> List[str]:
bodies = []
for r in raw:
t = trim_stop(r)
body = extract_body(t, entry_point)
if body:
completions.append(body)
bodies.append(body)
return bodies
passed = 0
for comp in completions:
if _execute_code(problem, comp):
passed += 1
n = len(completions)
c = passed
result = {"task_id": task_id, "n": n, "passed": c}
for k in k_values:
result[f"pass@{k}"] = round(_pass_at_k(n, c, k), 4)
all_pass_at_k[k].append(_pass_at_k(n, c, k))
results[task_id] = result
summary = {}
for k in k_values:
vals = all_pass_at_k[k]
summary[f"pass@{k}"] = round(float(np.mean(vals)), 4)
results["_summary"] = summary
def generate_all(
engine: InferenceEngine,
problems: Sequence[dict],
cfg: EvalConfig,
) -> List[dict]:
results = []
for problem in tqdm.tqdm(problems, desc="Generating", unit="problem"):
raw = generate_batch(
engine,
problem["prompt"],
cfg.num_samples,
cfg.batch_size,
cfg.max_tokens,
cfg.temperature,
cfg.top_p,
cfg.top_k,
)
bodies = extract_completions(raw, problem["entry_point"])
results.append(
dict(
task_id=problem["task_id"],
entry_point=problem["entry_point"],
prompt=problem["prompt"],
test=problem["test"],
completions=bodies,
)
)
return results
def main():
parser = argparse.ArgumentParser(description="HumanEval benchmark")
parser.add_argument(
"--param_path", type=str, default="./params", help="Model directory"
def execute_one(args: tuple) -> bool:
full_code, entry_point, timeout = args
try:
r = subprocess.run(
[sys.executable, "-c", full_code],
capture_output=True,
timeout=timeout,
)
parser.add_argument(
"--data_path",
return r.returncode == 0
except subprocess.TimeoutExpired:
return False
except Exception:
return False
def test_one(item: dict, cfg: EvalConfig) -> Tuple[str, int, int]:
from concurrent.futures import ProcessPoolExecutor
task_id = item["task_id"]
completions = item["completions"]
codes = [
(
item["prompt"] + c + "\n" + item["test"],
item["entry_point"],
cfg.test_timeout,
)
for c in completions
]
n = len(codes)
passed = 0
with ProcessPoolExecutor(max_workers=cfg.test_workers) as pool:
for ok in pool.map(execute_one, codes):
if ok:
passed += 1
return task_id, n, passed
def test_all(
items: Sequence[dict],
cfg: EvalConfig,
) -> Iterator[Tuple[str, int, int]]:
for item in tqdm.tqdm(items, desc="Testing", unit="problem"):
yield test_one(item, cfg)
def pass_at_k(n: int, c: int, k: int) -> float:
if n - c < k:
return 1.0
return 1.0 - float(prod(1.0 - k / np.arange(n - c + 1, n + 1)))
def score_results(
results: Iterator[Tuple[str, int, int]],
k_values: Tuple[int, ...],
) -> Dict:
# filter to k <= n (peek first result to get n)
first = next(results)
results = itertools.chain([first], results)
n = first[1]
k_values = tuple(k for k in k_values if k <= n)
scores = {k: [] for k in k_values}
output = {}
for task_id, n, passed in results:
entry = {"task_id": task_id, "n": n, "passed": passed}
for k in k_values:
pk = round(pass_at_k(n, passed, k), 4)
entry[f"pass@{k}"] = pk
scores[k].append(pk)
output[task_id] = entry
summary = {}
for k in k_values:
vals = scores[k]
summary[f"pass@{k}"] = round(float(np.mean(vals)), 4)
output["_summary"] = summary
return output
def run_pipeline(cfg: EvalConfig) -> Dict:
if cfg.test_only:
with open(cfg.test_only, encoding="utf-8") as f:
generated = json.load(f)
else:
download(HUMANEVAL_URL, cfg.data_path)
problems = load_jsonl(cfg.data_path)
if cfg.problem_indices:
problems = [problems[i] for i in cfg.problem_indices if i < len(problems)]
engine = create_engine(cfg.param_path, cfg.batch_size)
try:
generated = generate_all(engine, problems, cfg)
finally:
engine.shutdown()
if cfg.output:
mid = cfg.output.replace(".json", "_completions.json")
save_json(mid, generated)
print(f"Completions saved to {mid}")
if cfg.generate_only:
return {}
results = test_all(generated, cfg)
scored = score_results(results, cfg.k_values)
return scored
def parse_args(argv: Optional[List[str]] = None) -> EvalConfig:
p = argparse.ArgumentParser(description="HumanEval benchmark")
p.add_argument("--param_path", type=str, default="./params")
p.add_argument("--data_path", type=str, default="./humaneval/HumanEval.jsonl")
p.add_argument("--output", type=str, default=None)
p.add_argument(
"--test_only",
type=str,
default="./humaneval/HumanEval.jsonl",
help="HumanEval JSONL file (auto-download if missing)",
)
parser.add_argument("--output", type=str, default=None, help="Output JSON path")
parser.add_argument(
"--num_samples",
type=int,
default=200,
help="Completions per problem",
)
parser.add_argument(
"--max_tokens", type=int, default=512, help="Max generation tokens"
)
parser.add_argument(
"--temperature", type=float, default=0.8, help="Sampling temperature"
)
parser.add_argument("--top_p", type=float, default=0.95, help="Top-p sampling")
parser.add_argument("--top_k", type=int, default=50, help="Top-k sampling")
parser.add_argument(
"--batch_size", type=int, default=1, help="Inference batch size"
)
parser.add_argument(
"--problems",
type=int,
nargs="+",
default=None,
help="Specific problem indices (0-based)",
help="Skip generation, test existing completions JSON",
)
args = parser.parse_args()
_download_humaneval(args.data_path)
problems = _load_problems(args.data_path)
if args.problems:
problems = [problems[i] for i in args.problems if i < len(problems)]
model = AutoModel.from_pretrained(args.param_path)
tokenizer = AutoTokenizer.from_pretrained(args.param_path)
model.to(device="cuda", dtype=torch.bfloat16)
engine = InferenceEngine(
model=model,
tokenizer=tokenizer,
max_batch_size=args.batch_size,
p.add_argument(
"--generate_only", action="store_true", help="Only generate, skip testing"
)
p.add_argument("--num_samples", type=int, default=200)
p.add_argument("--max_tokens", type=int, default=512)
p.add_argument("--temperature", type=float, default=0.8)
p.add_argument("--top_p", type=float, default=0.95)
p.add_argument("--top_k", type=int, default=50)
p.add_argument("--batch_size", type=int, default=32)
p.add_argument("--test_workers", type=int, default=8)
p.add_argument("--test_timeout", type=float, default=3.0)
p.add_argument("--problems", type=int, nargs="+", default=None)
args = p.parse_args(argv)
results = evaluate(
engine=engine,
problems=problems,
return EvalConfig(
param_path=args.param_path,
data_path=args.data_path,
output=args.output,
test_only=args.test_only,
generate_only=args.generate_only,
num_samples=args.num_samples,
max_tokens=args.max_tokens,
temperature=args.temperature,
top_p=args.top_p,
top_k=args.top_k,
batch_size=args.batch_size,
k_values=(1, 10, 100),
test_workers=args.test_workers,
test_timeout=args.test_timeout,
problem_indices=args.problems,
)
summary = results.pop("_summary")
def report(scored: Dict):
summary = scored.pop("_summary", {})
print(f"\n{'=' * 60}")
for k, v in summary.items():
print(f" {k}: {v:.2%}")
print(f"{'=' * 60}")
scored["_summary"] = summary
if args.output:
results["_summary"] = summary
with open(args.output, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"Results saved to {args.output}")
engine.shutdown()
def main():
cfg = parse_args()
scored = run_pipeline(cfg)
report(scored)
if cfg.output:
save_json(cfg.output, scored)
print(f"Results saved to {cfg.output}")
if __name__ == "__main__":
+383 -92
View File
@@ -1,24 +1,23 @@
"""IFD (Instruction Following Difficulty) data quality scoring.
Computes IFD scores for instruction-response pairs to guide data selection.
IFD = conditional_NLL / unconditional_NLL, where:
IFD = conditional_NLL / unconditional_NLL
- conditional_NLL: average CE loss on response tokens given instruction context
- unconditional_NLL: average CE loss on response tokens alone
- Messages format: plain text concatenation (no chat template)
- Plain format: raw instr_key + resp_key fields
Higher IFD (close to 1) = instruction provides less help = harder sample.
Lower IFD (close to 0) = instruction provides strong guidance = easy sample.
IFD > 1 = instruction misleads the model = likely low-quality data.
Usage::
python scripts/eval/ifd.py --param_path ./params \
--input data.jsonl --output data_with_ifd.jsonl \
--instr_key instruction --resp_key response
v2 changelog:
- Same token set: unconditional pass prefixes resp with a plain-text sentinel
(default ``\\n``; use ``--sentinel_text ""`` for bos/pad fallback).
Both branches predict the identical N resp tokens.
Single-token answers (rl=1) are now supported.
- ctx_len tracked in output
- skip_reason for None samples (no more silent None)
- --per_token for per-token IFD breakdown
"""
import argparse
import json
import statistics
import torch
import torch.nn.functional as F
@@ -28,120 +27,396 @@ from astrai.model import AutoModel
from astrai.tokenize import AutoTokenizer
def compute_ifd(
def _pack_bins(pairs, max_len):
"""BFD bin packing: pack (c+r) into bins of max total length."""
indexed = sorted(enumerate(pairs), key=lambda x: -(len(x[1][0]) + len(x[1][1])))
bins = []
lengths = []
for orig_idx, (c, r) in indexed:
size = len(c) + len(r)
best_bin = -1
for bi, rem in enumerate(lengths):
if rem >= size:
if best_bin < 0 or rem < lengths[best_bin]:
best_bin = bi
if best_bin >= 0:
bins[best_bin].append((orig_idx, c, r))
lengths[best_bin] -= size
else:
bins.append([(orig_idx, c, r)])
lengths.append(max_len - size)
return bins
def _resolve_sentinel_ids(tokenizer, sentinel_text):
"""Tokenize the sentinel text for the unconditional pass prefix.
Falls back to bos/pad_token_id when sentinel_text is empty or
cannot be encoded.
"""
if sentinel_text:
ids = tokenizer.encode(sentinel_text, add_special_tokens=False)
if ids:
return ids
for attr in ("bos_token_id", "pad_token_id", "eos_token_id"):
tid = getattr(tokenizer, attr, None)
if tid is not None:
return [tid]
return [0]
@torch.inference_mode()
def _score_batch(
pairs, model, device, max_len=2048, sentinel_ids=None, per_token=False
):
"""BFD-packed IFD with text-sentinel-anchored unconditional pass.
Conditional: (ctx + resp[0..i-1]) resp[i], i = 0..N-1
Unconditional: (<sentinel> + resp[0..i-1]) resp[i], i = 0..N-1
Both branches predict the identical N response tokens. A short
plain-text sentinel gives the unconditional pass a prefix so that
every response token can be predicted. Single-token answers (rl=1)
are supported.
"""
if not pairs:
return []
if sentinel_ids is None:
sentinel_ids = [0]
bins = _pack_bins(pairs, max_len)
result = [None] * len(pairs)
# ---- conditional pass (packed, per-document position IDs) ----
for bin_items in bins:
seq_ids = []
global_pos = []
doc_ids = []
doc_offsets = []
for di, (orig_idx, c, r) in enumerate(bin_items):
ctx_len = len(c)
start = len(seq_ids)
item_len = len(c) + len(r)
seq_ids.extend(c)
seq_ids.extend(r)
end = len(seq_ids)
global_pos.extend(range(item_len))
doc_ids.extend([di] * item_len)
doc_offsets.append((start, end, orig_idx, ctx_len))
full_ids = torch.tensor([seq_ids], device=device, dtype=torch.long)
pos_ids = torch.tensor([global_pos], device=device, dtype=torch.long)
seq_len = len(seq_ids)
causal = torch.tril(
torch.ones(seq_len, seq_len, dtype=torch.bool, device=device)
)
doc_t = torch.tensor([doc_ids], device=device)
doc_mask = doc_t.unsqueeze(-1) == doc_t.unsqueeze(-2)
attn_mask = (causal & doc_mask[0]).unsqueeze(0).unsqueeze(0)
logits_full = model(full_ids, position_ids=pos_ids, input_mask=attn_mask)[
"logits"
][0]
for start, end, orig_idx, ctx_len in doc_offsets:
rl = end - start - ctx_len
resp_start = start + ctx_len - 1
resp_logits = logits_full[resp_start : end - 1]
resp_targets = torch.tensor(
seq_ids[start + ctx_len : end], device=device, dtype=torch.long
)
cond_losses = F.cross_entropy(
resp_logits, resp_targets, reduction="none"
).cpu()
result[orig_idx] = {
"_cond_losses": cond_losses,
"_rl": rl,
"_ctx_len": ctx_len,
}
# ---- unconditional pass (sentinel-prefixed, batched 2D) ----
valid_items = [
(
i,
result[i]["_rl"],
result[i]["_ctx_len"],
result[i]["_cond_losses"],
pairs[i][1],
)
for i in range(len(pairs))
if result[i] is not None and "_cond_losses" in result[i]
]
if not valid_items:
return result
valid_items.sort(key=lambda x: -x[1])
prefix_len = len(sentinel_ids)
max_rl = prefix_len + max(rl for _, rl, _, _, _ in valid_items)
bsz = len(valid_items)
u_batch = torch.zeros(bsz, max_rl, dtype=torch.long, device=device)
for ri, (_, rl, _, _, r_ids) in enumerate(valid_items):
u_batch[ri, :prefix_len] = torch.tensor(sentinel_ids, dtype=torch.long)
u_batch[ri, prefix_len : prefix_len + rl] = torch.tensor(
r_ids, dtype=torch.long
)
logits_resp = model(u_batch)["logits"]
for ri, (orig_idx, rl, ctx_len, cond_losses, _) in enumerate(valid_items):
unp_logits = logits_resp[ri, prefix_len - 1 : prefix_len - 1 + rl]
unp_targets = u_batch[ri, prefix_len : prefix_len + rl]
uncond_losses = F.cross_entropy(unp_logits, unp_targets, reduction="none").cpu()
L_cond = cond_losses.mean().item()
L_uncond = uncond_losses.mean().item()
ifd = L_cond / L_uncond if L_uncond > 0 else None
out = {
"L_cond": round(L_cond, 6),
"L_uncond": round(L_uncond, 6),
"ifd": round(ifd, 6) if ifd is not None else None,
"ctx_len": ctx_len,
"resp_len": rl,
}
if per_token:
per = [
(round(c.item() / u.item(), 6) if u.item() > 0 else None)
for c, u in zip(cond_losses, uncond_losses)
]
out["ifd_per_token"] = per
result[orig_idx] = out
return result
def _trim(context_ids, resp_ids, max_len):
"""Truncate to fit max_len, keeping response intact if possible."""
if len(resp_ids) > max_len // 2:
resp_ids = resp_ids[: max_len // 2]
full_ids = context_ids + resp_ids
if len(full_ids) <= max_len:
return context_ids, resp_ids
overflow = len(full_ids) - max_len
if overflow >= len(context_ids):
return [], resp_ids[:max_len]
return context_ids[overflow:], resp_ids
def score_plain(
model,
tokenizer,
instruction: str,
response: str,
device: str,
max_len: int = 2048,
) -> dict:
instr_ids = tokenizer.encode(instruction)
resp_ids = tokenizer.encode(response)
if not resp_ids:
instruction,
response,
device,
max_len=2048,
sentinel_ids=None,
per_token=False,
):
"""Compute IFD for a single instruction-response pair (plain format)."""
ctx_ids = tokenizer.encode(instruction, add_special_tokens=False)
resp_ids = tokenizer.encode(response, add_special_tokens=False)
ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len)
if not ctx_ids or not resp_ids:
return {
"L_cond": None,
"L_uncond": None,
"ifd": None,
"error": "empty response",
"skip_reason": "empty ctx or resp",
}
return _score_batch(
[(ctx_ids, resp_ids)],
model,
device,
max_len,
sentinel_ids=sentinel_ids,
per_token=per_token,
)[0]
# Truncate instruction if total length exceeds max_len
qa_len = len(instr_ids) + len(resp_ids)
if qa_len > max_len:
overflow = qa_len - max_len
instr_ids = instr_ids[overflow:]
instr_len = len(instr_ids)
resp_len = len(resp_ids)
# Conditional: instruction + response
qa_ids = instr_ids + resp_ids
qa_tensor = torch.tensor([qa_ids], device=device, dtype=torch.long)
with torch.inference_mode():
logits_qa = model(qa_tensor)["logits"][0] # [qa_len, vocab]
resp_logits = logits_qa[instr_len - 1 : -1] # predict response tokens
resp_targets = torch.tensor(resp_ids, device=device, dtype=torch.long)
L_cond = F.cross_entropy(resp_logits, resp_targets, reduction="mean").item()
# Unconditional: response alone
resp_tensor = torch.tensor([resp_ids], device=device, dtype=torch.long)
with torch.inference_mode():
logits_resp = model(resp_tensor)["logits"][0] # [resp_len, vocab]
unp_logits = logits_resp[:-1] # causal shift
unp_targets = resp_tensor[0, 1:]
L_uncond = F.cross_entropy(unp_logits, unp_targets, reduction="mean").item()
ifd = L_cond / L_uncond if L_uncond > 0 else None
def score_messages(
model, tokenizer, messages, device, max_len=2048, sentinel_ids=None, per_token=False
):
"""Compute IFD for each assistant turn in a messages array."""
turns = []
for i, msg in enumerate(messages):
if msg.get("role") != "assistant":
continue
ctx_text = "\n\n".join(m["content"] for m in messages[:i])
ctx_ids = tokenizer.encode(ctx_text)
resp_ids = tokenizer.encode(msg["content"], add_special_tokens=False)
ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len)
if ctx_ids and resp_ids:
turns.append((ctx_ids, resp_ids))
if not turns:
return None
raw_scores = _score_batch(
turns, model, device, max_len, sentinel_ids=sentinel_ids, per_token=per_token
)
valid = [s for s in raw_scores if s is not None and s.get("ifd") is not None]
if not valid:
return {"ifd": None, "ifd_turns": raw_scores}
avg = sum(s["ifd"] for s in valid) / len(valid)
return {
"L_cond": round(L_cond, 6),
"L_uncond": round(L_uncond, 6),
"ifd": round(ifd, 6) if ifd is not None else None,
"instr_len": instr_len,
"resp_len": resp_len,
"error": None,
"ifd": avg,
"ifd_detail": valid[0] if len(valid) == 1 else None,
"ifd_turns": raw_scores,
}
def process_file(
param_path: str,
input_file: str,
output_file: str,
instr_key: str,
resp_key: str,
max_len: int,
param_path,
input_file,
output_file,
instr_key,
resp_key,
max_len=2048,
data_format="plain",
batch_size=1,
device=None,
sentinel_text="\n",
per_token=False,
):
if device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if device == "cuda" else torch.float32
dtype = torch.bfloat16 if "cuda" in device else torch.float32
model = AutoModel.from_pretrained(param_path)
tokenizer = AutoTokenizer.from_pretrained(param_path)
model.to(device=device, dtype=dtype)
model.eval()
with open(input_file, "r", encoding="utf-8") as f:
sentinel_ids = _resolve_sentinel_ids(tokenizer, sentinel_text)
with open(input_file, encoding="utf-8") as f:
data = [json.loads(line) for line in f if line.strip()]
results = []
ifd_values = []
all_ifds = []
buffer = []
with torch.inference_mode():
for item in tqdm.tqdm(data, desc="Computing IFD", unit="sample"):
instruction = item[instr_key]
response = item[resp_key]
scores = compute_ifd(
model, tokenizer, instruction, response, device, max_len
if data_format == "messages":
turns = []
for i, msg in enumerate(item.get("messages", [])):
if msg.get("role") != "assistant":
continue
ctx_text = "\n\n".join(m["content"] for m in item["messages"][:i])
ctx_ids = tokenizer.encode(ctx_text)
resp_ids = tokenizer.encode(msg["content"], add_special_tokens=False)
ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len)
if ctx_ids and resp_ids:
turns.append((ctx_ids, resp_ids))
if not turns:
results.append(
{
**item,
"ifd": None,
"skip_reason": "no valid assistant turns",
"ifd_turns": [],
}
)
continue
buffer.append((item, turns, "messages"))
else:
ctx_ids = tokenizer.encode(item[instr_key], add_special_tokens=False)
resp_ids = tokenizer.encode(item[resp_key], add_special_tokens=False)
ctx_ids, resp_ids = _trim(ctx_ids, resp_ids, max_len)
if not ctx_ids or not resp_ids:
results.append(
{
**item,
"ifd": None,
"ifd_detail": {"skip_reason": "empty ctx or resp"},
}
)
continue
buffer.append((item, [(ctx_ids, resp_ids)], "plain"))
if len(buffer) >= batch_size:
_flush_buffer(
buffer,
results,
all_ifds,
model,
device,
max_len,
sentinel_ids,
per_token,
)
if buffer:
_flush_buffer(
buffer, results, all_ifds, model, device, max_len, sentinel_ids, per_token
)
ifd_values.append(scores["ifd"])
results.append({**item, "ifd": scores["ifd"], "ifd_detail": scores})
with open(output_file, "w", encoding="utf-8") as f:
for item in results:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
valid_ifd = [v for v in ifd_values if v is not None]
valid_ifd = [v for v in all_ifds if v is not None]
if valid_ifd:
import statistics
print(f"\n{'=' * 50}")
print(f" Samples: {len(data)}")
print(f" Valid IFD: {len(valid_ifd)}")
print(f" Skipped: {len(data) - len(valid_ifd)}")
print(f" Mean IFD: {statistics.mean(valid_ifd):.4f}")
print(f" Median IFD: {statistics.median(valid_ifd):.4f}")
if len(valid_ifd) > 1:
print(f" Stdev IFD: {statistics.stdev(valid_ifd):.4f}")
print(f" Min IFD: {min(valid_ifd):.4f}")
print(f" Max IFD: {max(valid_ifd):.4f}")
print(f"{'=' * 50}")
print(f"Results saved to {output_file}")
def _flush_buffer(
buffer, results, all_ifds, model, device, max_len, sentinel_ids, per_token
):
all_pairs = []
indices = []
for item, turns, fmt in buffer:
start = len(all_pairs)
all_pairs.extend(turns)
indices.append((item, turns, fmt, start, len(all_pairs)))
raw = _score_batch(
all_pairs,
model,
device,
max_len,
sentinel_ids=sentinel_ids,
per_token=per_token,
)
for item, turns, fmt, start, end in indices:
turn_scores = raw[start:end]
if fmt == "messages":
valid = [
s for s in turn_scores if s is not None and s.get("ifd") is not None
]
if not valid:
results.append({**item, "ifd": None, "ifd_turns": turn_scores})
else:
avg = sum(s["ifd"] for s in valid) / len(valid)
all_ifds.append(avg)
results.append(
{
**item,
"ifd": avg,
"ifd_detail": valid[0] if len(valid) == 1 else None,
"ifd_turns": turn_scores,
}
)
else:
score = turn_scores[0]
all_ifds.append(score.get("ifd"))
results.append({**item, "ifd": score.get("ifd"), "ifd_detail": score})
buffer.clear()
def main():
parser = argparse.ArgumentParser(
description="Compute IFD scores for instruction-response data"
@@ -149,23 +424,34 @@ def main():
parser.add_argument("--param_path", type=str, required=True, help="Model directory")
parser.add_argument("--input", type=str, required=True, help="Input JSONL file")
parser.add_argument("--output", type=str, required=True, help="Output JSONL file")
parser.add_argument("--max_len", type=int, default=2048, help="Max token length")
parser.add_argument(
"--instr_key",
"--format",
type=str,
default="instruction",
help="Key for instruction field",
default="plain",
choices=["plain", "messages"],
help="Input format",
)
parser.add_argument(
"--resp_key",
type=str,
default="response",
help="Key for response field",
"--instr_key", type=str, default="instruction", help="Key for instruction field"
)
parser.add_argument(
"--max_len",
type=int,
default=2048,
help="Max token length (instruction truncated to fit)",
"--resp_key", type=str, default="response", help="Key for response field"
)
parser.add_argument(
"--batch_size", type=int, default=8, help="Batch size for model forward passes"
)
parser.add_argument("--device", type=str, default=None, help="Device (e.g. cuda:0)")
parser.add_argument(
"--sentinel_text",
type=str,
default="\n",
help='Plain-text prefix for unconditional pass (default: "\\n"). Use "" for bos/pad fallback.',
)
parser.add_argument(
"--per_token",
action="store_true",
help="Include per-token IFD breakdown in output",
)
args = parser.parse_args()
@@ -176,6 +462,11 @@ def main():
args.instr_key,
args.resp_key,
args.max_len,
data_format=args.format,
batch_size=args.batch_size,
device=args.device,
sentinel_text=args.sentinel_text,
per_token=args.per_token,
)
+12 -3
View File
@@ -343,14 +343,20 @@ def verify_response(response: str, instruction_id: str, kwargs: dict) -> Optiona
def generate_one(
engine: InferenceEngine,
tokenizer: AutoTokenizer,
prompt: str,
max_tokens: int,
temperature: float,
top_p: float,
top_k: int,
) -> str:
formatted = tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
tokenize=False,
add_generation_prompt=True,
)
output = engine.generate(
prompt=prompt,
prompt=formatted,
stream=False,
max_tokens=max_tokens,
temperature=temperature,
@@ -364,6 +370,7 @@ def generate_one(
def evaluate(
engine: InferenceEngine,
tokenizer: AutoTokenizer,
problems: List[dict],
max_tokens: int,
temperature: float,
@@ -385,7 +392,7 @@ def evaluate(
samples = []
for _ in range(num_samples):
response = generate_one(
engine, prompt, max_tokens, temperature, top_p, top_k
engine, tokenizer, prompt, max_tokens, temperature, top_p, top_k
)
samples.append(response)
@@ -536,6 +543,7 @@ def main():
model = AutoModel.from_pretrained(args.param_path)
tokenizer = AutoTokenizer.from_pretrained(args.param_path)
model.to(device="cuda", dtype=torch.bfloat16)
model.eval()
engine = InferenceEngine(
model=model,
@@ -545,6 +553,7 @@ def main():
results = evaluate(
engine=engine,
tokenizer=tokenizer,
problems=problems,
max_tokens=args.max_tokens,
temperature=args.temperature,
@@ -562,7 +571,7 @@ def main():
print(f" Unsupported: {summary['unsupported_constraints']}")
print(f"{'=' * 60}")
print(f"\nPer-type accuracy:")
print("\nPer-type accuracy:")
for inst_id, stats in sorted(summary["per_type_accuracy"].items()):
print(
f" {inst_id:50s} {stats['accuracy']:.2%} "
+1 -1
View File
@@ -86,7 +86,7 @@ def process_file(
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run perplexity with a Khaosz model.")
parser = argparse.ArgumentParser(description="Perplexity evaluation on JSONL text.")
parser.add_argument(
"--param_path", type=str, required=True, help="Path to the model directory."
)
+153
View File
@@ -0,0 +1,153 @@
"""ROUGE evaluation (manual implementation, no external deps).
Computes ROUGE-1, ROUGE-2, ROUGE-L precision, recall, and F1.
Usage::
# Batch evaluation from JSONL (each line: {"reference": ..., "candidate": ...})
python scripts/eval/evaluate_rouge.py --data_path preds.jsonl --output results.json
# As a library
from scripts.eval.evaluate_rouge import compute_rouge
scores = compute_rouge("the cat sat on the mat", "the cat sat")
"""
import argparse
import json
from collections import Counter
from typing import Dict, List, Tuple
def _tokenize(text: str) -> List[str]:
return text.split()
def _ngrams(tokens: List[str], n: int) -> Counter:
return Counter(zip(*[tokens[i:] for i in range(n)]))
def _lcs(x: List[str], y: List[str]) -> int:
m, n = len(x), len(y)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
xi = x[i - 1]
dpi = dp[i]
dpi_1 = dp[i - 1]
for j in range(1, n + 1):
if xi == y[j - 1]:
dpi[j] = dpi_1[j - 1] + 1
else:
dpi[j] = dpi_1[j] if dpi_1[j] > dpi[j - 1] else dpi[j - 1]
return dp[m][n]
def _f1(precision: float, recall: float) -> float:
if precision + recall == 0:
return 0.0
return 2 * precision * recall / (precision + recall)
def _rouge_n(ref_tokens: List[str], cand_tokens: List[str], n: int) -> Dict[str, float]:
ref_ngrams = _ngrams(ref_tokens, n)
cand_ngrams = _ngrams(cand_tokens, n)
overlap = sum((cand_ngrams & ref_ngrams).values())
cand_total = sum(cand_ngrams.values())
ref_total = sum(ref_ngrams.values())
precision = overlap / cand_total if cand_total > 0 else 0.0
recall = overlap / ref_total if ref_total > 0 else 0.0
f1 = _f1(precision, recall)
return {"precision": precision, "recall": recall, "f1": f1}
def _rouge_l(ref_tokens: List[str], cand_tokens: List[str]) -> Dict[str, float]:
lcs_len = _lcs(ref_tokens, cand_tokens)
ref_len = len(ref_tokens)
cand_len = len(cand_tokens)
recall = lcs_len / ref_len if ref_len > 0 else 0.0
precision = lcs_len / cand_len if cand_len > 0 else 0.0
f1 = _f1(precision, recall)
return {"precision": precision, "recall": recall, "f1": f1}
def compute_rouge(
reference: str, candidate: str, n: int = 2
) -> Dict[str, Dict[str, float]]:
"""Compute ROUGE-N (1..n) and ROUGE-L scores.
Returns::
{
"rouge-1": {"precision": ..., "recall": ..., "f1": ...},
"rouge-2": {"precision": ..., "recall": ..., "f1": ...},
"rouge-l": {"precision": ..., "recall": ..., "f1": ...},
}
"""
ref_tokens = _tokenize(reference)
cand_tokens = _tokenize(candidate)
results = {}
for i in range(1, n + 1):
results[f"rouge-{i}"] = _rouge_n(ref_tokens, cand_tokens, i)
results["rouge-l"] = _rouge_l(ref_tokens, cand_tokens)
return results
def evaluate_file(data_path: str) -> Dict:
with open(data_path, "r", encoding="utf-8") as f:
pairs = [json.loads(line) for line in f if line.strip()]
agg = {
k: {"precision": 0.0, "recall": 0.0, "f1": 0.0}
for k in ("rouge-1", "rouge-2", "rouge-l")
}
per_item = []
for item in pairs:
ref = item["reference"]
cand = item["candidate"]
scores = compute_rouge(ref, cand)
per_item.append({**item, "scores": scores})
for k, v in scores.items():
agg[k]["precision"] += v["precision"]
agg[k]["recall"] += v["recall"]
agg[k]["f1"] += v["f1"]
n = len(pairs)
for k in agg:
agg[k] = {m: v / n for m, v in agg[k].items()}
return {"num_samples": n, "aggregate": agg, "per_item": per_item}
def main():
parser = argparse.ArgumentParser(description="ROUGE evaluation")
parser.add_argument(
"--data_path", required=True, help="JSONL with reference/candidate per line"
)
parser.add_argument("--output", type=str, default=None, help="Output JSON path")
args = parser.parse_args()
results = evaluate_file(args.data_path)
agg = results["aggregate"]
print(f"Samples: {results['num_samples']}")
print()
for metric in ("rouge-1", "rouge-2", "rouge-l"):
s = agg[metric]
print(
f" {metric:8s} P={s['precision']:.4f} R={s['recall']:.4f} F1={s['f1']:.4f}"
)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\nSaved to {args.output}")
if __name__ == "__main__":
main()
+123 -73
View File
@@ -1,12 +1,13 @@
"""Benchmark AutoRegressiveLM with KVCache"""
import argparse
from dataclasses import dataclass
from typing import Any, Dict
import torch
from astrai.config import AutoRegressiveLMConfig
from astrai.inference import KVCache
from astrai.inference import ContiguousCache, PageCache
from astrai.model.transformer import AutoRegressiveLM
@@ -24,41 +25,14 @@ class GenerationBenchmark:
config: AutoRegressiveLMConfig,
device: str = "cuda",
dtype: torch.dtype = torch.bfloat16,
page_size: int = 128,
cache_type: str = "contiguous",
):
self.config = config
self.device = device
self.dtype = dtype
self.cache_type = cache_type
self.model = AutoRegressiveLM(config).to(device=device, dtype=dtype)
self.model.eval()
head_dim = config.dim // config.n_heads
n_pages = (config.max_len * 4 + page_size - 1) // page_size
self._page_cache = KVCache(
config.n_layers,
n_pages,
page_size,
config.n_kv_heads,
head_dim,
device,
dtype,
)
def _prepare_inputs(self, batch_size: int, prompt_length: int, total_length: int):
prompt_ids = torch.randint(
low=0,
high=self.config.vocab_size,
size=(batch_size, prompt_length),
device=self.device,
dtype=torch.long,
)
gen_ids = torch.randint(
low=0,
high=self.config.vocab_size,
size=(batch_size, total_length - prompt_length),
device=self.device,
dtype=torch.long,
)
return prompt_ids, gen_ids
@torch.inference_mode()
def run_prefill_benchmark(
@@ -68,8 +42,12 @@ class GenerationBenchmark:
num_trials: int = 10,
) -> BenchmarkResult:
for _ in range(3):
prompt_ids, _ = self._prepare_inputs(
batch_size, prompt_length, prompt_length
prompt_ids = torch.randint(
0,
self.config.vocab_size,
(batch_size, prompt_length),
device=self.device,
dtype=torch.long,
)
_ = self.model(prompt_ids)
torch.cuda.synchronize()
@@ -78,12 +56,15 @@ class GenerationBenchmark:
total_tokens = batch_size * prompt_length * num_trials
for trial in range(num_trials):
prompt_ids, _ = self._prepare_inputs(
batch_size, prompt_length, prompt_length
prompt_ids = torch.randint(
0,
self.config.vocab_size,
(batch_size, prompt_length),
device=self.device,
dtype=torch.long,
)
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
_ = self.model(prompt_ids)
end.record()
@@ -107,6 +88,7 @@ class GenerationBenchmark:
"prompt_length": prompt_length,
"dtype": str(self.dtype),
"device": self.device,
"cache": "none",
},
)
@@ -120,29 +102,56 @@ class GenerationBenchmark:
) -> BenchmarkResult:
total_time = 0.0
total_tokens = batch_size * gen_length * num_trials
page_size = self._page_cache.page_size
for trial in range(num_trials):
prompt_ids, gen_ids = self._prepare_inputs(
batch_size,
prompt_length,
prompt_length + gen_length,
)
n_pages = (prompt_length + gen_length + page_size - 1) // page_size
total = n_pages * batch_size
pages = []
for _ in range(total):
p = self._page_cache._pool.alloc()
assert p >= 0, "OOM"
pages.append(p)
page_table = torch.tensor(
[pages[i * n_pages : (i + 1) * n_pages] for i in range(batch_size)],
dtype=torch.long,
prompt_ids = torch.randint(
0,
self.config.vocab_size,
(batch_size, prompt_length),
device=self.device,
dtype=torch.long,
)
gen_ids = torch.randint(
0,
self.config.vocab_size,
(batch_size, gen_length),
device=self.device,
dtype=torch.long,
)
cv = self._page_cache.bind(page_table, total_len=prompt_length)
head_dim = self.config.dim // self.config.n_heads
max_seq = prompt_length + gen_length
if self.cache_type == "contiguous":
cache = ContiguousCache(
self.config.n_layers,
batch_size,
max_seq,
self.config.n_kv_heads,
head_dim,
self.device,
self.dtype,
)
else:
page_size = 128
n_pages = (max_seq + page_size - 1) // page_size * batch_size
cache = PageCache(
self.config.n_layers,
n_pages,
page_size,
self.config.n_kv_heads,
head_dim,
self.device,
self.dtype,
)
task_ids = [f"b{i}" for i in range(batch_size)]
for tid in task_ids:
cache.task_alloc(tid, [0] * max_seq)
for p in range(max_seq):
cache.task_extend(tid, p)
cv = cache.bind_tasks(task_ids, prompt_length, self.device)
_ = self.model(
prompt_ids,
paged_cache=cv,
@@ -152,37 +161,35 @@ class GenerationBenchmark:
.unsqueeze(0)
.expand(batch_size, -1),
)
torch.cuda.synchronize()
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
start.record()
current_pos = prompt_length
for i in range(gen_length):
input_token = gen_ids[:, i : i + 1]
cv = self._page_cache.bind(page_table, total_len=current_pos + 1)
pos = prompt_length + i
cv = cache.bind_tasks(task_ids, pos + 1, self.device)
_ = self.model(
input_token,
gen_ids[:, i : i + 1],
paged_cache=cv,
position_ids=torch.full(
(batch_size, 1),
current_pos,
pos,
dtype=torch.long,
device=self.device,
),
)
current_pos += 1
end.record()
torch.cuda.synchronize()
for tid in task_ids:
cache.task_free(tid)
trial_time = start.elapsed_time(end) / 1000
total_time += trial_time
for idx in pages:
self._page_cache._pool.free(idx)
print(
f" Trial {trial + 1}/{num_trials}: {gen_length} tokens in {trial_time:.3f}s "
f"({gen_length / trial_time:.1f} tok/s)"
@@ -199,6 +206,7 @@ class GenerationBenchmark:
"gen_length": gen_length,
"dtype": str(self.dtype),
"device": self.device,
"cache": self.cache_type,
},
)
@@ -216,6 +224,42 @@ def print_benchmark_result(result: BenchmarkResult):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="AutoRegressiveLM benchmark")
parser.add_argument(
"--device", type=str, default="cuda", help="Device (default: cuda)"
)
parser.add_argument(
"--dtype",
type=str,
default="bfloat16",
choices=["bfloat16", "float16", "float32"],
help="Dtype",
)
parser.add_argument(
"--cache",
type=str,
default="contiguous",
choices=["contiguous", "paged"],
help="KV cache type",
)
parser.add_argument("--batch_size", type=int, default=4, help="Batch size")
parser.add_argument("--prompt_length", type=int, default=512, help="Prompt length")
parser.add_argument("--gen_length", type=int, default=128, help="Generation length")
parser.add_argument("--num_trials", type=int, default=5, help="Number of trials")
parser.add_argument(
"--prefill_only", action="store_true", help="Run prefill benchmark only"
)
parser.add_argument(
"--decode_only", action="store_true", help="Run decoding benchmark only"
)
args = parser.parse_args()
dtype_map = {
"bfloat16": torch.bfloat16,
"float16": torch.float16,
"float32": torch.float32,
}
config = AutoRegressiveLMConfig(
vocab_size=10000,
dim=1536,
@@ -227,23 +271,29 @@ if __name__ == "__main__":
norm_eps=1e-5,
)
benchmark = GenerationBenchmark(config)
benchmark = GenerationBenchmark(
config, device=args.device, dtype=dtype_map[args.dtype], cache_type=args.cache
)
print("=" * 80)
print("Running AutoRegressiveLM Generation Benchmark (KVCache)")
print(
f"Running AutoRegressiveLM Benchmark (device={args.device}, dtype={args.dtype})"
)
print("=" * 80)
if not args.decode_only:
prefill_result = benchmark.run_prefill_benchmark(
batch_size=4,
prompt_length=512,
num_trials=5,
batch_size=args.batch_size,
prompt_length=args.prompt_length,
num_trials=args.num_trials,
)
print_benchmark_result(prefill_result)
if not args.prefill_only:
gen_result = benchmark.run_decoding_benchmark(
batch_size=4,
prompt_length=512,
gen_length=128,
num_trials=5,
batch_size=args.batch_size,
prompt_length=args.prompt_length,
gen_length=args.gen_length,
num_trials=args.num_trials,
)
print_benchmark_result(gen_result)
+16 -9
View File
@@ -1,5 +1,6 @@
import argparse
import json
from typing import Optional
import torch
@@ -17,7 +18,7 @@ def processor(
top_p: float,
question_key: str,
response_key: str,
max_tokens: int,
max_tokens: Optional[int],
batch_size: int,
):
# Load model and tokenizer
@@ -72,7 +73,7 @@ def processor(
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run generate with a Khaosz model.")
parser = argparse.ArgumentParser(description="Batch generation from JSONL file.")
parser.add_argument(
"--param_path", type=str, required=True, help="Path to the model directory."
@@ -93,36 +94,42 @@ if __name__ == "__main__":
"--question_key",
type=str,
default="question",
help="Key for the question in the input JSON.",
help="Key for the question in the input JSON (default: question).",
)
parser.add_argument(
"--response_key",
type=str,
default="response",
help="Key for the response in the output JSON.",
help="Key for the response in the output JSON (default: response).",
)
parser.add_argument(
"--temperature",
type=float,
default=0.60,
help="Temperature for generating responses.",
help="Temperature for generating responses (default: 0.60).",
)
parser.add_argument(
"--top_k", type=int, default=30, help="Top-k value for generating responses."
"--top_k",
type=int,
default=30,
help="Top-k value for generating responses (default: 30).",
)
parser.add_argument(
"--top_p",
type=float,
default=0.95,
help="Top-p value for generating responses.",
help="Top-p value for generating responses (default: 0.95).",
)
parser.add_argument(
"--batch_size", type=int, default=1, help="Batch size for generating responses."
"--batch_size",
type=int,
default=1,
help="Batch size for generating responses (default: 1).",
)
parser.add_argument(
"--max_tokens",
type=int,
default=2048,
default=None,
help="Maximum tokens to generate (default: model config max_len).",
)
+191 -50
View File
@@ -1,9 +1,11 @@
import argparse
import os
from functools import partial
from typing import Any, Dict
import torch
import torch.optim as optim
from torch import Tensor, nn
from astrai.config import AutoRegressiveLMConfig, TrainConfig
from astrai.dataset import DatasetFactory
@@ -12,6 +14,84 @@ from astrai.model.components.decoder_block import DecoderBlock
from astrai.trainer import SchedulerFactory, Trainer
class MuonMix(optim.Optimizer):
"""Combined Muon (matrix) + AdamW (non-matrix) optimizer."""
def __init__(
self,
model: nn.Module,
lr: float = 3e-4,
weight_decay: float = 0.1,
momentum: float = 0.95,
nesterov: bool = True,
ns_steps: int = 5,
adjust_lr_fn: str = "match_rms_adamw",
):
defaults = dict(
lr=lr,
weight_decay=weight_decay,
momentum=momentum,
nesterov=nesterov,
ns_steps=ns_steps,
adjust_lr_fn=adjust_lr_fn,
)
params = [p for p in model.parameters() if p.requires_grad]
super().__init__(params, defaults)
matrix_params: list[Tensor] = []
other_params: list[Tensor] = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
if (
param.dim() >= 2
and "norm" not in name
and "bias" not in name
and "embed" not in name
and "lm_head" not in name
):
matrix_params.append(param)
else:
other_params.append(param)
self.muon = optim.Muon(
matrix_params,
lr=lr,
weight_decay=weight_decay,
momentum=momentum,
nesterov=nesterov,
ns_steps=ns_steps,
adjust_lr_fn=adjust_lr_fn,
)
self.adamw = optim.AdamW(
[{"params": other_params, "weight_decay": 0.0}],
lr=lr,
betas=(0.9, 0.95),
fused=True,
)
self.param_groups = [*self.muon.param_groups, *self.adamw.param_groups]
@torch.no_grad()
def step(self, closure=None):
self.muon.step(closure)
self.adamw.step(closure)
def zero_grad(self, set_to_none: bool = True):
self.muon.zero_grad(set_to_none)
self.adamw.zero_grad(set_to_none)
def state_dict(self) -> Dict[str, Any]:
return {
"muon": self.muon.state_dict(),
"adamw": self.adamw.state_dict(),
}
def load_state_dict(self, state_dict: Dict[str, Any]):
self.muon.load_state_dict(state_dict["muon"])
self.adamw.load_state_dict(state_dict["adamw"])
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train the AutoRegressiveLM model.")
@@ -64,22 +144,35 @@ def parse_args() -> argparse.Namespace:
help="Max gradient norm for clipping.",
)
parser.add_argument(
"--adamw_beta1",
"--weight_decay",
type=float,
default=0.9,
help="Beta1 for AdamW optimizer.",
default=0.1,
help="Weight decay (applied to Muon matrix params; non-matrix use 0).",
)
parser.add_argument(
"--adamw_beta2",
"--muon_momentum",
type=float,
default=0.95,
help="Beta2 for AdamW optimizer.",
help="Momentum factor for Muon optimizer.",
)
parser.add_argument(
"--adamw_weight_decay",
type=float,
default=0.01,
help="Weight decay for AdamW optimizer.",
"--muon_nesterov",
action=argparse.BooleanOptionalAction,
default=True,
help="Enable Nesterov momentum for Muon.",
)
parser.add_argument(
"--muon_ns_steps",
type=int,
default=5,
help="Newton-Schulz iteration steps for Muon.",
)
parser.add_argument(
"--muon_adjust_lr",
type=str,
default="match_rms_adamw",
choices=["original", "match_rms_adamw"],
help="Muon learning rate adjustment strategy.",
)
parser.add_argument(
"--random_seed", type=int, default=3407, help="Random seed for reproducibility."
@@ -150,8 +243,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--metrics",
nargs="*",
default=["loss", "lr"],
help="Metrics to log (e.g. --metrics loss lr val_loss). Default: loss lr.",
default=["loss", "lr", "grad_norm"],
help="Metrics to log (e.g. --metrics loss lr val_loss). Default: loss lr grad_norm.",
)
parser.add_argument(
"--log_dir",
@@ -159,12 +252,6 @@ def parse_args() -> argparse.Namespace:
default="checkpoint/logs",
help="Directory for metric logs.",
)
parser.add_argument(
"--log_interval",
type=int,
default=100,
help="Number of batch iterations between metric logs.",
)
parser.add_argument(
"--grpo_sync_interval",
type=int,
@@ -175,7 +262,10 @@ def parse_args() -> argparse.Namespace:
"--start_epoch", type=int, default=0, help="Start epoch for training."
)
parser.add_argument(
"--start_batch", type=int, default=0, help="Start batch for training."
"--start_samples",
type=int,
default=0,
help="Start samples (per rank) for training.",
)
parser.add_argument(
@@ -221,6 +311,44 @@ def parse_args() -> argparse.Namespace:
help="NEFTune noise alpha (0=disabled, typical: 5.0).",
)
parser.add_argument(
"--schedule_type",
type=str,
default="cosine",
choices=["cosine", "sgdr", "wsd"],
help="Learning rate scheduler type.",
)
parser.add_argument(
"--min_rate",
type=float,
default=None,
help="Minimum LR as fraction of base LR. Uses scheduler default if not set (cosine/sgdr: 0.05, wsd: 0.0).",
)
parser.add_argument(
"--cycle_length",
type=int,
default=None,
help="SGDR first cycle length in steps. Defaults to total_steps - warmup_steps.",
)
parser.add_argument(
"--t_mult",
type=int,
default=2,
help="SGDR cycle length multiplier per restart.",
)
parser.add_argument(
"--stable_steps",
type=int,
default=None,
help="WSD stable plateau steps. Required when --schedule_type wsd.",
)
parser.add_argument(
"--decay_steps",
type=int,
default=None,
help="WSD decay steps. Defaults to total_steps - warmup_steps - stable_steps.",
)
args = parser.parse_args()
return args
@@ -230,8 +358,8 @@ def create_model(config):
return AutoRegressiveLM(config).to(dtype=torch.bfloat16)
def create_optimizer(model, **kwargs) -> optim.Optimizer:
return optim.AdamW(model.parameters(), fused=True, **kwargs)
def create_optimizer(model, **kwargs) -> MuonMix:
return MuonMix(model, **kwargs)
def create_scheduler(
@@ -262,11 +390,10 @@ def train(
train_type: str,
param_path: str,
data_root_path: str,
max_lr: float,
n_epoch: int,
batch_per_device: int,
start_epoch: int,
start_batch: int,
start_samples: int,
grad_accum_steps: int,
warmup_ratio: float,
ckpt_interval: int,
@@ -275,17 +402,7 @@ def train(
val_step: int,
metrics: list[str],
log_dir: str,
log_interval: int,
dpo_beta: float,
grpo_clip_eps: float,
grpo_kl_coef: float,
group_size: int,
grpo_sync_interval: int,
adamw_beta1: float,
adamw_beta2: float,
adamw_weight_decay: float,
max_grad_norm: float,
label_smoothing: float,
random_seed: int,
num_workers: int,
pin_memory: bool,
@@ -300,6 +417,13 @@ def train(
master_port: str,
start_method: str,
neftune_alpha: float,
schedule_type: str,
min_rate: float,
cycle_length: int,
t_mult: int,
stable_steps: int,
decay_steps: int,
**kwargs,
):
assert train_type in ["seq", "sft", "dpo", "grpo"]
assert os.path.exists(param_path)
@@ -309,17 +433,18 @@ def train(
# Load config
config_path = os.path.join(param_path, "config.json")
config = AutoRegressiveLMConfig.from_file(config_path)
config.neftune_alpha = neftune_alpha
if window_size is None:
window_size = config.max_len
strategy_kwargs = {
"beta": dpo_beta,
"label_smoothing": label_smoothing,
"clip_eps": grpo_clip_eps,
"kl_coef": grpo_kl_coef,
"group_size": group_size,
"sync_interval": grpo_sync_interval,
"beta": kwargs.pop("dpo_beta"),
"label_smoothing": kwargs.pop("label_smoothing"),
"clip_eps": kwargs.pop("grpo_clip_eps"),
"kl_coef": kwargs.pop("grpo_kl_coef"),
"group_size": kwargs.pop("group_size"),
"sync_interval": kwargs.pop("grpo_sync_interval"),
}
executor_kwargs = {
@@ -337,25 +462,42 @@ def train(
optimizer_fn = partial(
create_optimizer,
**{
"lr": max_lr,
"betas": (adamw_beta1, adamw_beta2),
"weight_decay": adamw_weight_decay,
},
lr=kwargs.pop("max_lr"),
weight_decay=kwargs.pop("weight_decay"),
momentum=kwargs.pop("muon_momentum"),
nesterov=kwargs.pop("muon_nesterov"),
ns_steps=kwargs.pop("muon_ns_steps"),
adjust_lr_fn=kwargs.pop("muon_adjust_lr"),
)
total_steps = compute_total_steps(
len(dataset), n_epoch, batch_per_device, nprocs, grad_accum_steps
)
warmup_steps = int(warmup_ratio * total_steps)
warmup_steps = min(warmup_steps, total_steps)
scheduler_kwargs = {"warmup_steps": warmup_steps}
if schedule_type == "cosine":
scheduler_kwargs["lr_decay_steps"] = total_steps - warmup_steps
elif schedule_type == "sgdr":
scheduler_kwargs["cycle_length"] = cycle_length or (total_steps - warmup_steps)
scheduler_kwargs["t_mult"] = t_mult
elif schedule_type == "wsd":
remaining = total_steps - warmup_steps
stable_steps_ = stable_steps or max(1, int(remaining * 0.8))
scheduler_kwargs["stable_steps"] = stable_steps_
scheduler_kwargs["decay_steps"] = max(
1, decay_steps or (remaining - stable_steps_)
)
if min_rate is not None:
scheduler_kwargs["min_rate"] = min_rate
scheduler_fn = partial(
create_scheduler,
**{
"schedule_type": "cosine",
"warmup_steps": min(warmup_steps, total_steps),
"lr_decay_steps": total_steps - min(warmup_steps, total_steps),
},
schedule_type=schedule_type,
**scheduler_kwargs,
)
grad_ckpt_modules = [DecoderBlock] if gradient_checkpointing else []
@@ -370,7 +512,7 @@ def train(
n_epoch=n_epoch,
batch_per_device=batch_per_device,
start_epoch=start_epoch,
start_batch=start_batch,
start_samples=start_samples,
ckpt_interval=ckpt_interval,
grad_accum_steps=grad_accum_steps,
max_grad_norm=max_grad_norm,
@@ -388,7 +530,6 @@ def train(
val_step=val_step,
metrics=metrics,
log_dir=log_dir,
log_interval=log_interval,
gradient_checkpointing_modules=grad_ckpt_modules,
executor_kwargs=executor_kwargs,
extra_kwargs=strategy_kwargs,
+61
View File
@@ -0,0 +1,61 @@
import os
import sys
from pathlib import Path
from setuptools import setup
from setuptools.command.build_ext import build_ext as _build_ext
sys.path.insert(0, str(Path(__file__).parent))
os.makedirs("astrai/extension", exist_ok=True)
def _should_build():
force = os.environ.get("CSRC_KERNELS", "").strip().lower()
if force == "true":
return True
if force == "false":
return False
try:
import shutil
import torch
return shutil.which("nvcc") is not None and torch.cuda.is_available()
except Exception:
return False
ext_modules = []
cmdclass = {}
if _should_build():
import torch
from torch.utils.cpp_extension import BuildExtension, CUDAExtension
from csrc.build import REGISTRY
_torch_lib = torch.utils.cpp_extension.library_paths()[0]
for name, info in REGISTRY.items():
ext_modules.append(
CUDAExtension(
f"astrai.extension.{name}",
info["sources"],
extra_compile_args={
"cxx": info["cxx_flags"],
"nvcc": info["nvcc_flags"],
},
extra_link_args=[f"-Wl,-rpath,{_torch_lib}"],
)
)
cmdclass["build_ext"] = BuildExtension
if not cmdclass:
class _NullBuildExt(_build_ext):
def build_extensions(self):
pass
cmdclass["build_ext"] = _NullBuildExt
setup(ext_modules=ext_modules, cmdclass=cmdclass)
+1 -1
View File
@@ -75,7 +75,7 @@ class MultiTurnDataset(Dataset):
class EarlyStoppingDataset(Dataset):
"""Dataset that triggers early stopping after a specified number of iterations."""
"""Dataset that triggers early stopping after consuming a specified number of samples."""
def __init__(self, length=10, stop_after=5):
self.length = length
+47
View File
@@ -1,3 +1,5 @@
import json
import os
import tempfile
import pytest
@@ -8,6 +10,11 @@ from astrai.config.preprocess_config import (
PipelineConfig,
ProcessingConfig,
)
from astrai.preprocessing.builder import (
MultiOutputMaskBuilder,
SectionedMaskBuilder,
SingleOutputMaskBuilder,
)
from astrai.tokenize import AutoTokenizer
_SPECIAL_TOKENS_CONFIG = {
@@ -200,3 +207,43 @@ def make_grpo_no_template_config():
mask_default="mask",
preprocessing=ProcessingConfig(max_seq_len=2048),
)
@pytest.fixture
def builder():
return SectionedMaskBuilder()
@pytest.fixture
def single_builder():
return SingleOutputMaskBuilder()
@pytest.fixture
def multi_builder():
return MultiOutputMaskBuilder()
@pytest.fixture
def tokenizer_dir(temp_dir, test_tokenizer):
d = os.path.join(temp_dir, "tok")
os.makedirs(d, exist_ok=True)
test_tokenizer._tokenizer.save(os.path.join(d, "tokenizer.json"))
with open(os.path.join(d, "tokenizer_config.json"), "w") as f:
json.dump(
{"special_tokens": {"pad_token": "<|_pad_|>", "unk_token": "<|_unk_|>"}}, f
)
return d
@pytest.fixture
def chat_tokenizer_dir(temp_dir, chat_tokenizer):
d = os.path.join(temp_dir, "tok")
os.makedirs(d, exist_ok=True)
chat_tokenizer._tokenizer.save(os.path.join(d, "tokenizer.json"))
with open(os.path.join(d, "tokenizer_config.json"), "w") as f:
json.dump(
{"special_tokens": _SPECIAL_TOKENS_CONFIG, "chat_template": _CHAT_TEMPLATE},
f,
)
return d
+9 -4
View File
@@ -25,7 +25,9 @@ def test_single_process():
scheduler.step()
checkpoint = Checkpoint(state_dict=model.state_dict(), epoch=3, iteration=30)
checkpoint = Checkpoint(
state_dict=model.state_dict(), epoch=3, consumed_samples=120
)
with tempfile.TemporaryDirectory() as tmpdir:
checkpoint.save(tmpdir)
@@ -33,7 +35,7 @@ def test_single_process():
loaded_checkpoint = Checkpoint.load(tmpdir)
assert loaded_checkpoint.epoch == 3
assert loaded_checkpoint.iteration == 30
assert loaded_checkpoint.consumed_samples == 120
def test_checkpoint_with_extra():
@@ -46,7 +48,10 @@ def test_checkpoint_with_extra():
"scheduler": {"last_epoch": 5},
}
checkpoint = Checkpoint(
state_dict=model.state_dict(), epoch=1, iteration=10, extra=extra
state_dict=model.state_dict(),
epoch=1,
consumed_samples=40,
extra=extra,
)
with tempfile.TemporaryDirectory() as tmpdir:
@@ -77,7 +82,7 @@ def simple_training():
checkpoint = Checkpoint(
state_dict=model.state_dict(),
epoch=2,
iteration=10,
consumed_samples=40,
)
rank = get_rank()
+195 -125
View File
@@ -1,42 +1,85 @@
import json
import os
import numpy as np
import pytest
import torch
from astrai.config.preprocess_config import PipelineConfig
from astrai.dataset.dataset import DatasetFactory, SEQDataset
from astrai.dataset.storage import (
H5Store,
StoreFactory,
detect_format,
)
from astrai.serialization import (
load_bin,
save_bin,
save_h5,
)
def _rand_seq(length, vocab=1000):
return torch.randint(0, vocab, (length,), dtype=torch.int64)
def _save_test_tokenizer(test_dir, tokenizer):
tokenizer_path = os.path.join(test_dir, "tokenizer")
os.makedirs(tokenizer_path, exist_ok=True)
tokenizer.save_pretrained(tokenizer_path)
return tokenizer_path
def _write_jsonl_dataset(test_dir, tokenizer_path, records, config_overrides=None):
data_dir = os.path.join(test_dir, "jsonl_data")
os.makedirs(data_dir, exist_ok=True)
with open(os.path.join(data_dir, "data.jsonl"), "w", encoding="utf-8") as f:
for record in records:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
config = {
"tokenizer_path": tokenizer_path,
"version": 1,
"input": {"sections": [{"field": "text", "action": "train"}]},
"preprocessing": {"max_seq_len": 128},
"output": {"position_ids_mode": "continuous"},
}
if config_overrides:
config.update(config_overrides)
with open(
os.path.join(data_dir, "dataset_config.json"), "w", encoding="utf-8"
) as f:
json.dump(config, f, ensure_ascii=False, indent=2)
return data_dir
def _make_seq_dataset(
test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs
):
if data is None:
data = {"sequence": [_rand_seq(seq_length)]}
save_h5(test_dir, name, data)
return DatasetFactory.load(
train_type,
test_dir,
window_size=load_kwargs.pop("window_size", 64),
**load_kwargs,
)
def test_dataset_loader_random_paths(base_test_env):
"""Test dataset loader with multiple random paths"""
test_dir = base_test_env["test_dir"]
# Create multiple mmap dataset directories with random data
num_files = np.random.randint(2, 5)
for i in range(num_files):
seq_length = np.random.randint(200, 400)
dummy_data = {
"sequence": [
torch.randint(0, 1000, (seq_length,), dtype=torch.int64)
for _ in range(10)
],
}
save_h5(test_dir, f"data_{i}", dummy_data)
# Test loading with multiple paths
loaded_dataset = DatasetFactory.load(
train_type="seq",
load_path=test_dir,
window_size=64,
dummy_data = {"sequence": [_rand_seq(seq_length) for _ in range(10)]}
loaded_dataset = _make_seq_dataset(
test_dir, f"data_{i}", seq_length, data=dummy_data
)
assert loaded_dataset is not None
assert len(loaded_dataset) > 0
@@ -54,23 +97,15 @@ def test_dpo_strategy_with_random_data(base_test_env):
"""Test DPO strategy with randomized preference data"""
test_dir = base_test_env["test_dir"]
# Create DPO-style data with memory mapping format
seq_length = np.random.randint(100, 200)
dummy_data = {
"chosen": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
"rejected": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
"chosen": [_rand_seq(seq_length)],
"rejected": [_rand_seq(seq_length)],
"chosen_mask": [torch.ones(seq_length, dtype=torch.bool)],
"rejected_mask": [torch.ones(seq_length, dtype=torch.bool)],
}
save_h5(test_dir, "dpo_data", dummy_data)
# Load DPO dataset
dpo_dataset = DatasetFactory.load(
train_type="dpo",
load_path=test_dir,
window_size=64,
dpo_dataset = _make_seq_dataset(
test_dir, "dpo_data", seq_length, train_type="dpo", data=dummy_data
)
assert dpo_dataset is not None
@@ -92,22 +127,14 @@ def test_sft_dataset_with_random_data(base_test_env):
"""Test SFT dataset with random data"""
test_dir = base_test_env["test_dir"]
# Create SFT-style data with memory mapping format
seq_length = np.random.randint(100, 200)
dummy_data = {
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
"sequence": [_rand_seq(seq_length)],
"loss_mask": [torch.ones(seq_length, dtype=torch.bool)],
"position_ids": [torch.arange(seq_length, dtype=torch.int32)],
}
save_h5(test_dir, "sft_data", dummy_data)
# Load SFT dataset
sft_dataset = DatasetFactory.load(
train_type="sft",
load_path=test_dir,
window_size=64,
sft_dataset = _make_seq_dataset(
test_dir, "sft_data", seq_length, train_type="sft", data=dummy_data
)
assert sft_dataset is not None
@@ -128,25 +155,11 @@ def test_dataset_with_custom_stride(base_test_env):
"""Test dataset with custom stride parameter"""
test_dir = base_test_env["test_dir"]
# Create test data
seq_length = 200
dummy_data = {
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
}
save_h5(test_dir, "stride_test_data", dummy_data)
# Test with custom stride
custom_stride = 32
dataset = DatasetFactory.load(
train_type="seq", load_path=test_dir, window_size=64, stride=custom_stride
)
dataset = _make_seq_dataset(test_dir, "stride_test_data", stride=custom_stride)
assert dataset is not None
assert len(dataset) > 0
# With stride 32 and window 64 on 200 length data, we should get more samples
# than with default stride (which equals window size)
default_stride_dataset = DatasetFactory.load(
train_type="seq",
load_path=test_dir,
@@ -157,25 +170,11 @@ def test_dataset_with_custom_stride(base_test_env):
def test_dataset_count_property(base_test_env):
"""Test the count property returns correct raw token count"""
test_dir = base_test_env["test_dir"]
seq_length = 200
dummy_data = {
"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)],
}
save_h5(test_dir, "count_test_data", dummy_data)
dataset = DatasetFactory.load(
train_type="seq",
load_path=test_dir,
window_size=64,
)
assert dataset.count == seq_length
assert dataset.count > len(dataset) # raw tokens > windows
assert len(dataset) == (seq_length - 1 - 64) // 64 + 1
dataset = _make_seq_dataset(test_dir, "count_test_data")
assert dataset.count == 200
assert dataset.count > len(dataset)
assert len(dataset) == (200 - 1 - 64) // 64 + 1
def test_empty_dataset_count():
@@ -186,17 +185,10 @@ def test_empty_dataset_count():
def test_dataset_too_short_for_window(base_test_env):
"""Dataset shorter than window_size returns __len__ == 0"""
test_dir = base_test_env["test_dir"]
seq_length = 30
save_h5(
test_dir,
"short",
{"sequence": [torch.randint(0, 1000, (seq_length,), dtype=torch.int64)]},
)
dataset = DatasetFactory.load("seq", test_dir, window_size=64)
dataset = _make_seq_dataset(test_dir, "short", seq_length=30)
assert len(dataset) == 0
assert dataset.count == seq_length
assert dataset.count == 30
def test_unloaded_dataset_getitem_raises():
@@ -220,12 +212,8 @@ def test_store_unloaded_len():
def test_store_fetch_begin_equals_end(base_test_env):
"""Store.fetch with begin == end returns empty tensor"""
test_dir = base_test_env["test_dir"]
dummy = {"sequence": [torch.randint(0, 1000, (100,), dtype=torch.int64)]}
save_h5(test_dir, "empty_fetch", dummy)
dataset = DatasetFactory.load("seq", test_dir, window_size=32)
dataset = _make_seq_dataset(test_dir, "empty_fetch", seq_length=100, window_size=32)
result = dataset.storage.fetch(10, 10, "sequence")
assert result.numel() == 0
@@ -299,12 +287,8 @@ def test_save_load_bin_roundtrip(base_test_env):
def test_mmap_store_load_and_fetch(base_test_env):
"""MmapStore loads bin data and fetches correctly"""
test_dir = base_test_env["test_dir"]
data = {
"sequence": [torch.randint(0, 1000, (200,), dtype=torch.int64)],
}
data = {"sequence": [_rand_seq(200)]}
save_bin(test_dir, data)
store = StoreFactory.create("bin")
@@ -317,14 +301,9 @@ def test_mmap_store_load_and_fetch(base_test_env):
def test_mmap_dataset_load(base_test_env):
"""DatasetFactory.load auto-detects bin format"""
test_dir = base_test_env["test_dir"]
data = {
"sequence": [torch.randint(0, 1000, (200,), dtype=torch.int64)],
}
data = {"sequence": [_rand_seq(200)]}
save_bin(test_dir, data)
dataset = DatasetFactory.load("seq", test_dir, window_size=64)
assert len(dataset) > 0
assert dataset.count == 200
@@ -348,19 +327,16 @@ def test_normalize_mixed_empty_key():
def test_grpo_dataset_dtype(base_test_env):
"""GRPODataset returns correct dtypes"""
test_dir = base_test_env["test_dir"]
seq_len = 100
data = {
"prompts": [torch.randint(0, 100, (seq_len,), dtype=torch.int32)],
"responses": [torch.randint(0, 100, (seq_len,), dtype=torch.int32)],
"masks": [torch.ones(seq_len, dtype=torch.int32)],
"rewards": [torch.ones(seq_len, dtype=torch.float32)],
dummy_data = {
"prompts": [torch.randint(0, 100, (100,), dtype=torch.int32)],
"responses": [torch.randint(0, 100, (100,), dtype=torch.int32)],
"masks": [torch.ones(100, dtype=torch.int32)],
"rewards": [torch.ones(100, dtype=torch.float32)],
}
save_h5(test_dir, "grpo_dtype", data)
dataset = DatasetFactory.load("grpo", test_dir, window_size=32)
dataset = _make_seq_dataset(
test_dir, "grpo_dtype", train_type="grpo", data=dummy_data, window_size=32
)
item = dataset[0]
assert item["prompts"].dtype == torch.long
@@ -370,18 +346,16 @@ def test_grpo_dataset_dtype(base_test_env):
def test_grpo_dataset_load(base_test_env):
"""GRPODataset loads and returns correct keys"""
test_dir = base_test_env["test_dir"]
seq_len = 200
data = {
"prompts": [torch.randint(0, 1000, (seq_len,), dtype=torch.int64)],
"responses": [torch.randint(0, 1000, (seq_len,), dtype=torch.int64)],
"masks": [torch.ones(seq_len, dtype=torch.int64)],
"rewards": [torch.rand(seq_len, dtype=torch.float32)],
dummy_data = {
"prompts": [_rand_seq(200)],
"responses": [_rand_seq(200)],
"masks": [torch.ones(200, dtype=torch.int64)],
"rewards": [torch.rand(200, dtype=torch.float32)],
}
save_h5(test_dir, "grpo_test", data)
dataset = DatasetFactory.load("grpo", test_dir, window_size=64)
dataset = _make_seq_dataset(
test_dir, "grpo_test", train_type="grpo", data=dummy_data
)
assert len(dataset) > 0
item = dataset[0]
assert "prompts" in item
@@ -400,7 +374,6 @@ def test_detect_format_bin_dir(base_test_env):
def test_store_fetch_multi_key(base_test_env):
"""Store.fetch with List[str] returns Dict[str, Tensor]"""
test_dir = base_test_env["test_dir"]
save_h5(
test_dir,
@@ -410,7 +383,6 @@ def test_store_fetch_multi_key(base_test_env):
"loss_mask": [torch.ones(100, dtype=torch.int64)],
},
)
store = StoreFactory.create("h5")
store.load(test_dir)
result = store.fetch(10, 20, ["sequence", "loss_mask"])
@@ -420,10 +392,8 @@ def test_store_fetch_multi_key(base_test_env):
def test_store_fetch_out_of_bounds(base_test_env):
"""Store.fetch raises ValueError for out-of-bounds indices"""
test_dir = base_test_env["test_dir"]
save_h5(test_dir, "bounds", {"sequence": [torch.randint(0, 100, (50,))]})
store = StoreFactory.create("h5")
store.load(test_dir)
with pytest.raises(ValueError, match="out of bounds"):
@@ -435,10 +405,110 @@ def test_store_fetch_out_of_bounds(base_test_env):
def test_dataset_load_explicit_storage_type(base_test_env):
"""DatasetFactory.load with explicit storage_type bypasses auto-detect"""
test_dir = base_test_env["test_dir"]
save_h5(test_dir, "explicit", {"sequence": [torch.randint(0, 100, (200,))]})
dataset = DatasetFactory.load("seq", test_dir, window_size=64, storage_type="h5")
dataset = _make_seq_dataset(test_dir, "explicit", storage_type="h5")
assert len(dataset) > 0
assert dataset.count == 200
def test_detect_format_jsonl_dir(base_test_env):
test_dir = base_test_env["test_dir"]
tokenizer_path = _save_test_tokenizer(test_dir, base_test_env["tokenizer"])
data_dir = _write_jsonl_dataset(
test_dir,
tokenizer_path,
[{"text": "hello world"}, {"text": "foo bar baz"}],
)
assert detect_format(data_dir) == "jsonl"
def test_jsonl_store_seq(base_test_env):
test_dir = base_test_env["test_dir"]
tokenizer_path = _save_test_tokenizer(test_dir, base_test_env["tokenizer"])
data_dir = _write_jsonl_dataset(
test_dir,
tokenizer_path,
[{"text": "hello world"}, {"text": "foo bar baz qux"}],
config_overrides={"preprocessing": {"max_seq_len": 128, "min_chars": 0}},
)
store = StoreFactory.create("jsonl")
store.load(data_dir)
assert len(store) > 0
assert "sequence" in store.keys
dataset = DatasetFactory.load("seq", data_dir, window_size=8)
assert len(dataset) > 0
item = dataset[0]
assert "input_ids" in item
assert "target_ids" in item
assert item["input_ids"].dtype == torch.long
def test_jsonl_store_sft(base_test_env):
test_dir = base_test_env["test_dir"]
tokenizer = base_test_env["tokenizer"]
tokenizer.set_chat_template(
"{% for message in messages %}{{ message['role'] }}:{{ message['content'] }}\n{% endfor %}"
)
tokenizer_path = _save_test_tokenizer(test_dir, tokenizer)
data_dir = _write_jsonl_dataset(
test_dir,
tokenizer_path,
[
{
"messages": [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
}
],
config_overrides={
"input": {
"sections": [{"field": "messages", "action": "$role", "template": True}]
},
"mask": {"system": "mask", "user": "mask", "assistant": "train"},
"mask_default": "mask",
},
)
store = StoreFactory.create("jsonl")
store.load(data_dir)
assert "sequence" in store.keys
assert "loss_mask" in store.keys
assert "position_ids" in store.keys
dataset = DatasetFactory.load("sft", data_dir, window_size=8)
item = dataset[0]
assert "input_ids" in item
assert "target_ids" in item
assert "loss_mask" in item
assert "position_ids" in item
assert item["loss_mask"].dtype == torch.bool
def test_jsonl_store_pipeline_config_roundtrip(base_test_env):
test_dir = base_test_env["test_dir"]
config_path = os.path.join(test_dir, "dataset_config.json")
with open(config_path, "w", encoding="utf-8") as f:
json.dump(
{
"tokenizer_path": os.path.join(test_dir, "tokenizer"),
"version": 1,
"input": {"sections": [{"field": "text", "action": "train"}]},
"mask": {"assistant": "train"},
"preprocessing": {"max_seq_len": 64},
"output": {"position_ids_mode": "doc_reset"},
},
f,
ensure_ascii=False,
indent=2,
)
with open(config_path, "r", encoding="utf-8") as f:
raw = json.load(f)
raw.pop("tokenizer_path")
config = PipelineConfig.from_dict(raw)
assert config.output.position_ids_mode == "doc_reset"
assert config.preprocessing.max_seq_len == 64
+107 -70
View File
@@ -1,3 +1,5 @@
import pytest
from astrai.config.preprocess_config import (
InputConfig,
OutputConfig,
@@ -6,7 +8,9 @@ from astrai.config.preprocess_config import (
)
from astrai.preprocessing.builder import (
MaskBuilderFactory,
MultiOutputMaskBuilder,
SectionedMaskBuilder,
SingleOutputMaskBuilder,
)
from tests.data.conftest import (
_CHAT_SECTIONS,
@@ -20,9 +24,8 @@ from tests.data.conftest import (
)
def test_chat_simple(chat_tokenizer):
def test_chat_simple(chat_tokenizer, builder):
config = make_chat_config()
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "system", "content": "You are helpful."},
@@ -46,9 +49,8 @@ def test_chat_simple(chat_tokenizer):
assert trained < total
def test_chat_mask_only_assistant(chat_tokenizer):
def test_chat_mask_only_assistant(chat_tokenizer, builder):
config = make_chat_config()
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "user", "content": "What is 2+2?"},
@@ -66,14 +68,22 @@ def test_chat_mask_only_assistant(chat_tokenizer):
assert len(masked) > 0
def test_chat_all_masked(chat_tokenizer):
@pytest.mark.parametrize(
"mask_rules,mask_default,expect_nonzero",
[
({"system": "mask", "user": "mask", "assistant": "mask"}, "mask", False),
({}, "train", True),
],
)
def test_chat_uniform_masking(
mask_rules, mask_default, expect_nonzero, chat_tokenizer, builder
):
config = PipelineConfig(
input=InputConfig(sections=_CHAT_SECTIONS),
mask={"system": "mask", "user": "mask", "assistant": "mask"},
mask_default="mask",
mask=mask_rules,
mask_default=mask_default,
preprocessing=ProcessingConfig(max_seq_len=2048),
)
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "system", "content": "You are helpful."},
@@ -81,35 +91,20 @@ def test_chat_all_masked(chat_tokenizer):
]
}
result = builder.build(item, config, chat_tokenizer)
assert sum(result["loss_mask"]) == 0
masked_count = sum(result["loss_mask"])
if expect_nonzero:
assert masked_count > 0
else:
assert masked_count == 0
def test_chat_all_trained(chat_tokenizer):
config = PipelineConfig(
input=InputConfig(sections=_CHAT_SECTIONS),
mask={},
mask_default="train",
preprocessing=ProcessingConfig(max_seq_len=2048),
)
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "Hi there!"},
]
}
result = builder.build(item, config, chat_tokenizer)
assert sum(result["loss_mask"]) == len(result["sequence"]) - 1
def test_chat_empty_messages(chat_tokenizer):
def test_chat_empty_messages(chat_tokenizer, builder):
config = make_chat_config()
builder = SectionedMaskBuilder()
assert builder.build({"messages": []}, config, chat_tokenizer) is None
assert builder.build({}, config, chat_tokenizer) is None
def test_chat_domain_extraction(chat_tokenizer):
def test_chat_domain_extraction(chat_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_CHAT_SECTIONS),
mask={"assistant": "train"},
@@ -117,7 +112,6 @@ def test_chat_domain_extraction(chat_tokenizer):
preprocessing=ProcessingConfig(max_seq_len=2048),
output=OutputConfig(domain_key="source"),
)
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "user", "content": "Hi"},
@@ -129,14 +123,13 @@ def test_chat_domain_extraction(chat_tokenizer):
assert result["domain"] == "wiki"
def test_chat_truncation(chat_tokenizer):
def test_chat_truncation(chat_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_CHAT_SECTIONS),
mask={"assistant": "train"},
mask_default="mask",
preprocessing=ProcessingConfig(max_seq_len=10),
)
builder = SectionedMaskBuilder()
item = {
"messages": [
{
@@ -151,18 +144,16 @@ def test_chat_truncation(chat_tokenizer):
assert len(result["loss_mask"]) == len(result["sequence"])
def test_instruction_basic(test_tokenizer):
def test_instruction_basic(test_tokenizer, builder):
config = make_instruction_config()
builder = SectionedMaskBuilder()
item = {"prompt": "Translate to French: Hello", "response": "Bonjour"}
result = builder.build(item, config, test_tokenizer)
assert result is not None
assert len(result["sequence"]) == len(result["loss_mask"])
def test_instruction_prompt_masked(test_tokenizer):
def test_instruction_prompt_masked(test_tokenizer, builder):
config = make_instruction_config()
builder = SectionedMaskBuilder()
item = {"prompt": "hello", "response": "world"}
result = builder.build(item, config, test_tokenizer)
mask = result["loss_mask"]
@@ -175,7 +166,7 @@ def test_instruction_prompt_masked(test_tokenizer):
assert all(m == 1 for m in mask[p_len:])
def test_instruction_train_on_prompt(test_tokenizer):
def test_instruction_train_on_prompt(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(
sections=[
@@ -185,7 +176,6 @@ def test_instruction_train_on_prompt(test_tokenizer):
),
preprocessing=ProcessingConfig(max_seq_len=2048),
)
builder = SectionedMaskBuilder()
item = {"prompt": "hello", "response": "world"}
result = builder.build(item, config, test_tokenizer)
mask = result["loss_mask"]
@@ -196,9 +186,8 @@ def test_instruction_train_on_prompt(test_tokenizer):
assert all(m == 1 for m in mask[:p_len])
def test_text_basic(test_tokenizer):
def test_text_basic(test_tokenizer, builder):
config = make_text_config()
builder = SectionedMaskBuilder()
item = {"text": "Hello world. This is a test document."}
result = builder.build(item, config, test_tokenizer)
assert result is not None
@@ -207,41 +196,37 @@ def test_text_basic(test_tokenizer):
assert "loss_mask" not in result
def test_text_empty(test_tokenizer):
def test_text_empty(test_tokenizer, builder):
config = make_text_config()
builder = SectionedMaskBuilder()
assert builder.build({"text": ""}, config, test_tokenizer) is None
assert builder.build({"text": " "}, config, test_tokenizer) is None
def test_text_too_short(test_tokenizer):
def test_text_too_short(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_TEXT_SECTIONS),
preprocessing=ProcessingConfig(min_chars=100),
)
builder = SectionedMaskBuilder()
assert builder.build({"text": "short"}, config, test_tokenizer) is None
def test_text_truncation(test_tokenizer):
def test_text_truncation(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_TEXT_SECTIONS),
preprocessing=ProcessingConfig(max_seq_len=3, min_chars=1),
)
builder = SectionedMaskBuilder()
item = {"text": "This is a very long text that should be truncated"}
result = builder.build(item, config, test_tokenizer)
assert len(result["sequence"]) <= 3
def test_sectioned_chat(chat_tokenizer):
def test_sectioned_chat(chat_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_CHAT_SECTIONS),
mask={"system": "mask", "user": "mask", "assistant": "train"},
mask_default="mask",
preprocessing=ProcessingConfig(max_seq_len=2048),
)
builder = SectionedMaskBuilder()
item = {
"messages": [
{"role": "user", "content": "What is 2+2?"},
@@ -255,12 +240,11 @@ def test_sectioned_chat(chat_tokenizer):
assert 0 in result["loss_mask"]
def test_sectioned_instruction(test_tokenizer):
def test_sectioned_instruction(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_INSTRUCTION_SECTIONS),
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=0),
)
builder = SectionedMaskBuilder()
item = {"prompt": "Q: Why?", "response": "A: Because."}
result = builder.build(item, config, test_tokenizer)
assert result is not None
@@ -269,40 +253,43 @@ def test_sectioned_instruction(test_tokenizer):
assert mask[-1] == 1
def test_sectioned_text(test_tokenizer):
def test_sectioned_text(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_TEXT_SECTIONS),
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=1),
)
builder = SectionedMaskBuilder()
item = {"text": "Hello world, this is a test."}
result = builder.build(item, config, test_tokenizer)
assert result is not None
assert "loss_mask" not in result
def test_sectioned_text_too_short(test_tokenizer):
def test_sectioned_text_too_short(test_tokenizer, builder):
config = PipelineConfig(
input=InputConfig(sections=_TEXT_SECTIONS),
preprocessing=ProcessingConfig(max_seq_len=2048, min_chars=100),
)
builder = SectionedMaskBuilder()
assert builder.build({"text": "short"}, config, test_tokenizer) is None
def test_factory_registered():
names = MaskBuilderFactory.list_registered()
assert "single" in names
assert "multi" in names
assert "sectioned" in names
def test_factory_create():
builder = MaskBuilderFactory.create("sectioned")
assert isinstance(builder, SectionedMaskBuilder)
single = MaskBuilderFactory.create("single")
assert isinstance(single, SingleOutputMaskBuilder)
multi = MaskBuilderFactory.create("multi")
assert isinstance(multi, MultiOutputMaskBuilder)
sectioned = MaskBuilderFactory.create("sectioned")
assert isinstance(sectioned, SectionedMaskBuilder)
def test_dpo_chat_basic(chat_tokenizer):
def test_dpo_chat_basic(chat_tokenizer, builder):
config = make_dpo_chat_config()
builder = SectionedMaskBuilder()
item = {
"chosen": [
{"role": "user", "content": "What is 2+2?"},
@@ -319,16 +306,14 @@ def test_dpo_chat_basic(chat_tokenizer):
assert "rejected" in result
assert "chosen_mask" in result
assert "rejected_mask" in result
assert "domain" in result
assert len(result["chosen"]) == len(result["chosen_mask"])
assert len(result["rejected"]) == len(result["rejected_mask"])
assert sum(result["chosen_mask"]) > 0
assert sum(result["rejected_mask"]) > 0
def test_dpo_chosen_only_trained(chat_tokenizer):
def test_dpo_chosen_only_trained(chat_tokenizer, builder):
config = make_dpo_chat_config()
builder = SectionedMaskBuilder()
item = {
"chosen": [
{"role": "user", "content": "Hi"},
@@ -346,15 +331,13 @@ def test_dpo_chosen_only_trained(chat_tokenizer):
assert 1 in result["rejected_mask"]
def test_dpo_missing_field_is_none(chat_tokenizer):
def test_dpo_missing_field_is_none(chat_tokenizer, builder):
config = make_dpo_chat_config()
builder = SectionedMaskBuilder()
assert builder.build({"chosen": [], "rejected": []}, config, chat_tokenizer) is None
def test_grpo_basic(chat_tokenizer):
def test_grpo_basic(chat_tokenizer, builder):
config = make_grpo_config()
builder = SectionedMaskBuilder()
item = {
"prompt": [{"role": "user", "content": "What is 2+2?"}],
"responses": ["4", "The answer is four", "Four", "2+2=4"],
@@ -370,9 +353,8 @@ def test_grpo_basic(chat_tokenizer):
assert result["rewards"] == [1.0, 0.5, 0.8, 0.2]
def test_grpo_response_tokens_all_trained(chat_tokenizer):
def test_grpo_response_tokens_all_trained(chat_tokenizer, builder):
config = make_grpo_config()
builder = SectionedMaskBuilder()
item = {
"prompt": [{"role": "user", "content": "Q"}],
"responses": ["A", "B"],
@@ -384,9 +366,8 @@ def test_grpo_response_tokens_all_trained(chat_tokenizer):
assert len(masks) == len(result["responses"])
def test_grpo_single_reward(chat_tokenizer):
def test_grpo_single_reward(chat_tokenizer, builder):
config = make_grpo_config()
builder = SectionedMaskBuilder()
item = {
"prompt": [{"role": "user", "content": "Q"}],
"responses": ["A"],
@@ -394,3 +375,59 @@ def test_grpo_single_reward(chat_tokenizer):
}
result = builder.build(item, config, chat_tokenizer)
assert result["rewards"] == [0.9]
def test_single_builder_matches_facade(chat_tokenizer, builder, single_builder):
config = make_chat_config()
item = {
"messages": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
]
}
facade_result = builder.build(item, config, chat_tokenizer)
single_result = single_builder.build(item, config, chat_tokenizer)
assert single_result == facade_result
def test_single_builder_rejects_multi_config(chat_tokenizer, single_builder):
config = make_dpo_chat_config()
item = {
"chosen": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
],
"rejected": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "5"},
],
}
assert single_builder.build(item, config, chat_tokenizer) is None
def test_multi_builder_matches_facade(chat_tokenizer, builder, multi_builder):
config = make_dpo_chat_config()
item = {
"chosen": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
],
"rejected": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "5"},
],
}
facade_result = builder.build(item, config, chat_tokenizer)
multi_result = multi_builder.build(item, config, chat_tokenizer)
assert multi_result == facade_result
def test_multi_builder_rejects_single_config(chat_tokenizer, multi_builder):
config = make_chat_config()
item = {
"messages": [
{"role": "user", "content": "What is 2+2?"},
{"role": "assistant", "content": "4"},
]
}
assert multi_builder.build(item, config, chat_tokenizer) is None
+8 -93
View File
@@ -10,9 +10,7 @@ from astrai.config.preprocess_config import (
from astrai.preprocessing.pipeline import Pipeline, filter_by_length
from tests.data.conftest import (
_CHAT_SECTIONS,
_CHAT_TEMPLATE,
_INSTRUCTION_SECTIONS,
_SPECIAL_TOKENS_CONFIG,
_TEXT_SECTIONS,
make_dpo_chat_config,
make_grpo_no_template_config,
@@ -26,19 +24,7 @@ def test_filter_by_length():
assert filter_by_length("just right", min_len=5, max_len=20)
def test_full_chat_pipeline(temp_dir, chat_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
chat_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": _SPECIAL_TOKENS_CONFIG,
"chat_template": _CHAT_TEMPLATE,
},
f,
)
def test_full_chat_pipeline(temp_dir, chat_tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "chat.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(
@@ -78,7 +64,7 @@ def test_full_chat_pipeline(temp_dir, chat_tokenizer):
config=config,
input_paths=[jsonl_path],
output_dir=out_dir,
tokenizer_path=tokenizer_dir,
tokenizer_path=chat_tokenizer_dir,
).run()
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
@@ -91,21 +77,7 @@ def test_full_chat_pipeline(temp_dir, chat_tokenizer):
assert meta["loss_mask"]["dtype"] == "int32"
def test_full_text_pipeline(temp_dir, test_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
test_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": {
"pad_token": "<|_pad_|>",
"unk_token": "<|_unk_|>",
}
},
f,
)
def test_full_text_pipeline(temp_dir, tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "text.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(
@@ -145,24 +117,9 @@ def test_full_text_pipeline(temp_dir, test_tokenizer):
meta = json.load(f)
assert "sequence" in meta
assert "loss_mask" not in meta
assert meta["sequence"]["dtype"] == "int32"
def test_full_instruction_pipeline(temp_dir, test_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
test_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": {
"pad_token": "<|_pad_|>",
"unk_token": "<|_unk_|>",
}
},
f,
)
def test_full_instruction_pipeline(temp_dir, tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "instruct.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(
@@ -206,25 +163,9 @@ def test_full_instruction_pipeline(temp_dir, test_tokenizer):
meta = json.load(f)
assert "sequence" in meta
assert "loss_mask" in meta
assert meta["sequence"]["dtype"] == "int32"
assert meta["loss_mask"]["dtype"] == "int32"
def test_dtype_override(temp_dir, test_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
test_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": {
"pad_token": "<|_pad_|>",
"unk_token": "<|_unk_|>",
}
},
f,
)
def test_dtype_override(temp_dir, tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "data.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(json.dumps({"prompt": "Q", "response": "A"}) + "\n")
@@ -252,19 +193,7 @@ def test_dtype_override(temp_dir, test_tokenizer):
assert meta["loss_mask"]["dtype"] == "bool"
def test_dpo_pipeline(temp_dir, chat_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
chat_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": _SPECIAL_TOKENS_CONFIG,
"chat_template": _CHAT_TEMPLATE,
},
f,
)
def test_dpo_pipeline(temp_dir, chat_tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "dpo.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(
@@ -288,7 +217,7 @@ def test_dpo_pipeline(temp_dir, chat_tokenizer):
config=make_dpo_chat_config(),
input_paths=[jsonl_path],
output_dir=out_dir,
tokenizer_path=tokenizer_dir,
tokenizer_path=chat_tokenizer_dir,
).run()
meta_path = os.path.join(out_dir, "__default__", "shard_0000", "meta.json")
@@ -302,21 +231,7 @@ def test_dpo_pipeline(temp_dir, chat_tokenizer):
assert "sequence" not in meta
def test_grpo_pipeline(temp_dir, test_tokenizer):
tokenizer_dir = os.path.join(temp_dir, "tok")
os.makedirs(tokenizer_dir, exist_ok=True)
test_tokenizer._tokenizer.save(os.path.join(tokenizer_dir, "tokenizer.json"))
with open(os.path.join(tokenizer_dir, "tokenizer_config.json"), "w") as f:
json.dump(
{
"special_tokens": {
"pad_token": "<|_pad_|>",
"unk_token": "<|_unk_|>",
}
},
f,
)
def test_grpo_pipeline(temp_dir, tokenizer_dir):
jsonl_path = os.path.join(temp_dir, "grpo.jsonl")
with open(jsonl_path, "w", encoding="utf-8") as f:
f.write(
+3 -3
View File
@@ -4,7 +4,7 @@ import torch
from astrai.inference import (
Allocator,
KVCache,
PageCache,
PagePool,
PrefixCache,
Storage,
@@ -161,7 +161,7 @@ def test_task_table_pop():
def test_kv_cache_task_extend_allocates():
cache = KVCache(
cache = PageCache(
n_layers=1,
n_pages=8,
page_size=64,
@@ -177,7 +177,7 @@ def test_kv_cache_task_extend_allocates():
def test_kv_cache_task_extend_fails_when_pool_full():
cache = KVCache(
cache = PageCache(
n_layers=1,
n_pages=2,
page_size=64,
+61 -144
View File
@@ -13,67 +13,28 @@ from astrai.inference.api.tool_parser import (
)
def test_scan_complete_simple():
end, complete = _scan_json('{"key": "value"}', 0)
assert complete is True
assert end == len('{"key": "value"}')
def test_scan_complete_nested():
text = '{"outer": {"inner": 1}}'
@pytest.mark.parametrize(
"text,expected_complete,check_end_eq_len",
[
('{"key": "value"}', True, True),
('{"outer": {"inner": 1}}', True, True),
('{"key": "value"', False, False),
('{"outer": {"inner": 1}', False, False),
('{"key": "a{b}c"} extra', True, False),
(r'{"key": "a\"b"}', True, False),
('{"a": {"b": {"c": {"d": {"e": 5}}}}}', True, True),
('{"items": [{"x": 1}, {"x": 2}]}', True, True),
('{"fn": "function() { return 1; }"}', True, False),
('{"key": "\u5317\u4eac"}', True, False),
],
)
def test_scan_json(text, expected_complete, check_end_eq_len):
end, complete = _scan_json(text, 0)
assert complete is True
assert complete is expected_complete
if check_end_eq_len:
assert end == len(text)
def test_scan_incomplete_unclosed():
end, complete = _scan_json('{"key": "value"', 0)
assert complete is False
def test_scan_incomplete_nested():
end, complete = _scan_json('{"outer": {"inner": 1}', 0)
assert complete is False
def test_scan_string_braces_ignored():
text = '{"key": "a{b}c"} extra'
end, complete = _scan_json(text, 0)
assert complete is True
def test_scan_escaped_quote_ignored():
text = r'{"key": "a\"b"}'
end, complete = _scan_json(text, 0)
assert complete is True
def test_scan_deeply_nested():
text = '{"a": {"b": {"c": {"d": {"e": 5}}}}}'
end, complete = _scan_json(text, 0)
assert complete is True
assert end == len(text)
def test_scan_array_with_braces():
text = '{"items": [{"x": 1}, {"x": 2}]}'
end, complete = _scan_json(text, 0)
assert complete is True
assert end == len(text)
def test_scan_code_in_string():
text = '{"fn": "function() { return 1; }"}'
end, complete = _scan_json(text, 0)
assert complete is True
def test_scan_unicode_chars():
text = '{"key": "\u5317\u4eac"}'
end, complete = _scan_json(text, 0)
assert complete is True
def test_find_single_tool_call():
text = '{"name": "get_weather", "arguments": {"city": "Beijing"}}'
results = _find_tool_calls(text)
@@ -141,10 +102,7 @@ def test_find_arguments_with_array():
def test_find_arguments_with_nested_array_of_objects():
text = (
'{"name": "batch", '
'"arguments": {"rows": [{"id": 1, "val": "a"}, {"id": 2, "val": "b"}]}}'
)
text = '{"name": "batch", "arguments": {"rows": [{"id": 1, "val": "a"}, {"id": 2, "val": "b"}]}}'
results = _find_tool_calls(text)
assert len(results) == 1
assert '"rows"' in results[0]["args"]
@@ -206,38 +164,26 @@ def test_find_extracts_correct_arg_start_position():
assert json_str == text
def test_partial_with_name():
result = _find_partial_tool_call('{"name": "func", "arguments": {"city"')
@pytest.mark.parametrize(
"text,expected_name,expected_complete",
[
('{"name": "func", "arguments": {"city"', "func", False),
('{"name": "func", "arguments": {"city": "BJ"}}', "func", None),
("plain text", None, None),
('{"nam', None, None),
('{"name": "deep", "arguments": {"a": {"b": {"c": ', "deep", None),
('{"name": "batch", "arguments": {"items": [1, 2, ', "batch", None),
],
)
def test_find_partial_tool_call(text, expected_name, expected_complete):
result = _find_partial_tool_call(text)
if expected_name is None:
assert result is None
else:
assert result is not None
assert result["name"] == "func"
assert result["complete"] is False
def test_partial_with_full_args():
result = _find_partial_tool_call('{"name": "func", "arguments": {"city": "BJ"}}')
assert result is not None
assert result["name"] == "func"
def test_partial_no_match():
assert _find_partial_tool_call("plain text") is None
def test_partial_no_name_yet():
assert _find_partial_tool_call('{"nam') is None
def test_partial_deeply_nested():
result = _find_partial_tool_call('{"name": "deep", "arguments": {"a": {"b": {"c": ')
assert result is not None
assert result["name"] == "deep"
assert '"a"' in result["args"]
def test_partial_array_incomplete():
result = _find_partial_tool_call('{"name": "batch", "arguments": {"items": [1, 2, ')
assert result is not None
assert result["name"] == "batch"
assert result["name"] == expected_name
if expected_complete is not None:
assert result["complete"] is expected_complete
def test_feed_plain_text():
@@ -269,7 +215,6 @@ def test_feed_tool_call_args_streaming():
parser = SimpleJsonToolParser()
d1 = parser.feed('{"name": "f", "arguments": {"x":')
d2 = parser.feed('{"name": "f", "arguments": {"x": "1"}}')
args_deltas = [
d
for batch in (d1, d2)
@@ -332,17 +277,6 @@ def test_feed_content_after_tool_call_is_not_emitted():
assert parser.has_tool_calls
def _collect_args_deltas(parser):
args_parts = []
for d in parser.feed(parser._text_buffer):
if "tool_calls" in d:
for tc in d["tool_calls"]:
fn = tc.get("function", {})
if "arguments" in fn and fn["arguments"]:
args_parts.append(fn["arguments"])
return args_parts
def _simulate_streaming(parser, text):
all_delta_names = []
all_args_chunks = []
@@ -447,7 +381,6 @@ def test_streaming_args_diff_only_emits_new_bytes():
parser = SimpleJsonToolParser()
step1 = parser.feed('{"name": "f", "arguments": {"city": "Bei')
step2 = parser.feed('{"name": "f", "arguments": {"city": "Beijing"}}')
all_args = []
for step in (step1, step2):
for d in step:
@@ -500,31 +433,21 @@ def test_parse_complete_with_content():
def test_parse_complete_multiple_tool_calls():
parser = SimpleJsonToolParser()
body = (
'{"name": "get_weather", "arguments": {"city": "Beijing"}}'
'{"name": "get_time", "arguments": {"tz": "Asia/Shanghai"}}'
)
body = '{"name": "get_weather", "arguments": {"city": "Beijing"}}{"name": "get_time", "arguments": {"tz": "Asia/Shanghai"}}'
result = parser.parse_complete(body)
assert result is not None
assert len(result["tool_calls"]) == 2
assert result["tool_calls"][0]["function"]["name"] == "get_weather"
assert result["tool_calls"][1]["function"]["name"] == "get_time"
assert "Beijing" in result["tool_calls"][0]["function"]["arguments"]
assert "Asia/Shanghai" in result["tool_calls"][1]["function"]["arguments"]
def test_parse_complete_complex_real_world():
parser = SimpleJsonToolParser()
body = (
'{"name": "send_email", '
'"arguments": {'
'"to": ["a@b.com", "c@d.com"], '
'"cc": null, '
'"subject": "Hello World", '
'"body": "This is a test email.", '
'"priority": 1, '
'"attachments": false'
"}}"
'{"name": "send_email", "arguments": {'
'"to": ["a@b.com", "c@d.com"], "cc": null, '
'"subject": "Hello World", "body": "This is a test email.", '
'"priority": 1, "attachments": false}}'
)
result = parser.parse_complete(body)
assert result is not None
@@ -539,11 +462,7 @@ def test_parse_complete_complex_real_world():
def test_parse_complete_content_with_multiple_tool_calls():
parser = SimpleJsonToolParser()
body = (
"I will do two things. "
'{"name": "f1", "arguments": {"a": 1}}'
'{"name": "f2", "arguments": {"b": 2}}'
)
body = 'I will do two things. {"name": "f1", "arguments": {"a": 1}}{"name": "f2", "arguments": {"b": 2}}'
result = parser.parse_complete(body)
assert result is not None
assert result["content"] == "I will do two things."
@@ -588,30 +507,29 @@ def test_feed_then_parse_complete_same_instance():
assert parser.has_tool_calls
def test_pattern_matches_basic():
assert _TOOL_CALL_HEAD_RE.search('{"name": "f"}')
def test_pattern_matches_with_whitespace():
assert _TOOL_CALL_HEAD_RE.search('{ "name" : "f"}')
def test_pattern_no_match_without_name():
assert _TOOL_CALL_HEAD_RE.search('{"other": 1}') is None
def test_pattern_match_mid_text():
assert _TOOL_CALL_HEAD_RE.search('prefix {"name": "f", "args": {}}') is not None
@pytest.mark.parametrize(
"text,matches",
[
('{"name": "f"}', True),
('{ "name" : "f"}', True),
('{"other": 1}', False),
('prefix {"name": "f", "args": {}}', True),
('{"name": "f"}', True), # match at start
(' {"name": "f"}', True),
],
)
def test_pattern_regex(text, matches):
result = _TOOL_CALL_HEAD_RE.search(text)
if matches:
assert result is not None
else:
assert result is None
def test_pattern_name_at_start():
assert _TOOL_CALL_HEAD_RE.match('{"name": "f"}')
def test_pattern_leading_whitespace():
assert _TOOL_CALL_HEAD_RE.search(' {"name": "f"}') is not None
def test_factory_register_and_create():
parser = ToolParserFactory.create("simple_json")
assert isinstance(parser, BaseToolParser)
@@ -661,7 +579,6 @@ def test_feed_token_ids_do_not_affect_parsing():
text, current_token_ids=[1, 2, 3], delta_token_ids=[3]
)
assert len(result_no) == len(result_with)
assert len(result_no) > 0
assert (
result_no[0]["tool_calls"][0]["function"]["name"]
== result_with[0]["tool_calls"][0]["function"]["name"]
+28 -68
View File
@@ -1,6 +1,13 @@
import json
import os
import tempfile
import pytest
import safetensors.torch as st
import torch
from astrai.config.model_config import EncoderConfig
from astrai.model.automodel import AutoModel
from astrai.model.encoder import EmbeddingEncoder
TINY_CONFIG = dict(
@@ -14,92 +21,56 @@ TINY_CONFIG = dict(
norm_eps=1e-5,
)
_device = "cuda" if torch.cuda.is_available() else "cpu"
def test_encoder_forward_mean():
config = EncoderConfig(**TINY_CONFIG)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
def _make_model(**kwargs):
config = EncoderConfig(**{**TINY_CONFIG, **kwargs})
return EmbeddingEncoder(config).to(device=_device)
@pytest.mark.parametrize("pooling_type", ["mean", "cls", "last"])
def test_encoder_forward_pooling(pooling_type):
model = _make_model(pooling_type=pooling_type)
model.eval()
batch_size, seq_len = 2, 8
input_ids = torch.randint(
0, config.vocab_size, (batch_size, seq_len), device=device
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
)
with torch.no_grad():
output = model(input_ids)
assert output.shape == (batch_size, config.dim)
assert not torch.isnan(output).any()
def test_encoder_forward_cls():
config = EncoderConfig(**{**TINY_CONFIG, "pooling_type": "cls"})
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
model.eval()
batch_size, seq_len = 2, 8
input_ids = torch.randint(
0, config.vocab_size, (batch_size, seq_len), device=device
)
with torch.no_grad():
output = model(input_ids)
assert output.shape == (batch_size, config.dim)
assert not torch.isnan(output).any()
def test_encoder_forward_last():
config = EncoderConfig(**{**TINY_CONFIG, "pooling_type": "last"})
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
model.eval()
batch_size, seq_len = 2, 8
input_ids = torch.randint(
0, config.vocab_size, (batch_size, seq_len), device=device
)
with torch.no_grad():
output = model(input_ids)
assert output.shape == (batch_size, config.dim)
assert output.shape == (batch_size, TINY_CONFIG["dim"])
assert not torch.isnan(output).any()
def test_encoder_forward_with_padding():
config = EncoderConfig(**TINY_CONFIG)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
model = _make_model()
model.eval()
batch_size, seq_len = 2, 8
input_ids = torch.randint(
0, config.vocab_size, (batch_size, seq_len), device=device
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
)
input_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=device)
input_mask = torch.ones(batch_size, seq_len, dtype=torch.bool, device=_device)
input_mask[:, 4:] = False
with torch.no_grad():
output = model(input_ids, input_mask=input_mask)
assert output.shape == (batch_size, config.dim)
assert output.shape == (batch_size, TINY_CONFIG["dim"])
assert not torch.isnan(output).any()
def test_encoder_normalize():
config = EncoderConfig(
**{**TINY_CONFIG, "pooling_type": "mean", "normalize_embeddings": True}
)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
model = _make_model(pooling_type="mean", normalize_embeddings=True)
model.eval()
batch_size, seq_len = 2, 8
input_ids = torch.randint(
0, config.vocab_size, (batch_size, seq_len), device=device
0, TINY_CONFIG["vocab_size"], (batch_size, seq_len), device=_device
)
with torch.no_grad():
@@ -110,24 +81,19 @@ def test_encoder_normalize():
def test_encoder_register():
from astrai.model.automodel import AutoModel
assert AutoModel.is_registered("embedding")
cls = AutoModel.get_component_class("embedding")
assert cls is EmbeddingEncoder
def test_encoder_from_transformer_checkpoint():
config = EncoderConfig(**TINY_CONFIG)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = EmbeddingEncoder(config).to(device=device)
model = _make_model()
state_dict = model.state_dict()
state_dict["lm_head.weight"] = torch.randn(
config.vocab_size, config.dim, device=device
TINY_CONFIG["vocab_size"], TINY_CONFIG["dim"], device=_device
)
new_model = EmbeddingEncoder(config).to(device=device)
new_model = _make_model()
new_model.load_state_dict(state_dict, strict=True)
for key in model.state_dict():
@@ -135,12 +101,6 @@ def test_encoder_from_transformer_checkpoint():
def test_encoder_save_load():
import json
import os
import tempfile
import safetensors.torch as st
test_dir = tempfile.mkdtemp(prefix="encoder_test_")
config_path = os.path.join(test_dir, "config.json")
weights_path = os.path.join(test_dir, "model.safetensors")
+1 -1
View File
@@ -52,7 +52,7 @@ def create_train_config(
batch_per_device: Batch size per device (default: 2)
grad_accum_steps: Gradient accumulation steps (default: 1)
max_grad_norm: Maximum gradient norm for clipping (default: 1.0)
ckpt_interval: Checkpoint save interval in iterations (default: 5)
ckpt_interval: Checkpoint save interval in optimizer steps (default: 5)
random_seed: Random seed for reproducibility (default: 42)
**kwargs: Additional arguments passed to TrainConfig
+4 -4
View File
@@ -44,14 +44,14 @@ def test_early_stopping_simulation(base_test_env, early_stopping_dataset):
pass
# Resume from latest checkpoint
load_dir = os.path.join(base_test_env["test_dir"], "epoch_0_iter_2")
load_dir = os.path.join(base_test_env["test_dir"], "epoch_0_step_1")
trainer = Trainer(train_config)
trainer.train(resume_dir=load_dir)
# Verify checkpoint was saved at expected iteration
load_dir = os.path.join(base_test_env["test_dir"], "epoch_1_iter_10")
# Verify checkpoint was saved at expected step
load_dir = os.path.join(base_test_env["test_dir"], "epoch_1_step_5")
import json
with open(os.path.join(load_dir, "meta.json")) as f:
meta = json.load(f)
assert meta["iteration"] == 10
assert meta["consumed_samples"] == 20