Compare commits

..

No commits in common. "8999ca89b84e2a0336b4357b7ef8bb4fe36b4cca" and "6715461a36c37356d259ccb15599af2075e617b7" have entirely different histories.

33 changed files with 362 additions and 1046 deletions

6
.gitignore vendored
View File

@ -5,10 +5,8 @@
!*/ !*/
# Allow specific file types and root files # Allow specific file types and root files
!astrai/**/*.py !*.py
!scripts/**/*.py !*.sh
!scripts/**/*.sh
!tests/**/*.py
# Allow GitHub files # Allow GitHub files
!/.github/** !/.github/**

View File

@ -21,7 +21,6 @@ classDiagram
class BaseModelConfig { class BaseModelConfig {
+Optional[str] model_type +Optional[str] model_type
+float neftune_alpha
+from_file(config_path) Self +from_file(config_path) Self
+to_file(config_path) +to_file(config_path)
} }
@ -59,12 +58,10 @@ classDiagram
+Optional[int] dim_ffn +Optional[int] dim_ffn
+Optional[int] max_len +Optional[int] max_len
+Optional[float] rope_theta +Optional[float] rope_theta
+str attn_type
+Optional[int] n_heads +Optional[int] n_heads
+Optional[int] n_kv_heads +Optional[int] n_kv_heads
+Optional[bool] use_qk_norm +Optional[bool] use_qk_norm
+Optional[bool] use_gated_attention +Optional[bool] use_gated_attention
+str ffn_type
+Optional[dict] rope_scaling +Optional[dict] rope_scaling
+Optional[str] pooling_type +Optional[str] pooling_type
+Optional[bool] normalize_embeddings +Optional[bool] normalize_embeddings
@ -121,7 +118,7 @@ classDiagram
+float max_grad_norm +float max_grad_norm
+list gradient_checkpointing_modules +list gradient_checkpointing_modules
+int start_epoch +int start_epoch
+int start_samples +int start_batch
+str ckpt_dir +str ckpt_dir
+int ckpt_interval +int ckpt_interval
+str log_dir +str log_dir
@ -139,9 +136,7 @@ classDiagram
+str start_method +str start_method
+str device_type +str device_type
+Optional[Dataset] val_dataset +Optional[Dataset] val_dataset
+Optional[float] val_split
+int val_step +int val_step
+float neftune_alpha
+str parallel_mode +str parallel_mode
+dict executor_kwargs +dict executor_kwargs
+dict extra_kwargs +dict extra_kwargs
@ -220,13 +215,12 @@ classDiagram
class Checkpoint { class Checkpoint {
+dict state_dict +dict state_dict
+int epoch +int epoch
+int consumed_samples +int iteration
+dict extra +dict extra
+dict meta +dict meta
+dict config +dict config
+save(save_dir) +save(save_dir)
+load(save_dir, broadcast) Checkpoint +load(save_dir, broadcast) Checkpoint
+load_any(save_dir, broadcast) Optional[Checkpoint]
} }
} }
@ -356,9 +350,7 @@ classDiagram
class Embedding { class Embedding {
+Parameter weight +Parameter weight
+float neftune_noise_alpha
+forward(x) Tensor +forward(x) Tensor
+set_neftune_alpha(alpha)
} }
} }
@ -415,9 +407,7 @@ classDiagram
+Dict _entries +Dict _entries
+register(name) decorator +register(name) decorator
+create(name, *args, **kwargs) T +create(name, *args, **kwargs) T
+get_component_class(name) Type
+list_registered() list +list_registered() list
+is_registered(name) bool
} }
class MaskBuilderFactory { class MaskBuilderFactory {
@ -446,15 +436,13 @@ classDiagram
+dict model_config +dict model_config
+BaseExecutor executor +BaseExecutor executor
+int epoch +int epoch
+int consumed_samples +int iteration
+float loss +float loss
+float grad_norm
+DataLoader val_dataloader +DataLoader val_dataloader
+float val_loss +float val_loss
+int world_size +int world_size
+int rank +int rank
+dict kwargs +dict kwargs
+optimizer_step() int
} }
class TrainContextBuilder { class TrainContextBuilder {
@ -606,6 +594,18 @@ classDiagram
+create(name, **kwargs) TrainCallback +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 { namespace inference {
@ -810,9 +810,7 @@ classDiagram
class ChatMessage { class ChatMessage {
+str role +str role
+Optional[str] content +str content
+Optional[List[Dict]] tool_calls
+Optional[str] tool_call_id
} }
class ChatCompletionRequest { class ChatCompletionRequest {
@ -829,8 +827,6 @@ classDiagram
+Optional[float] frequency_penalty +Optional[float] frequency_penalty
+Optional[Dict[int, float]] logit_bias +Optional[Dict[int, float]] logit_bias
+Optional[str] user +Optional[str] user
+Optional[List[ToolDef]] tools
+Optional[Union[str, Dict]] tool_choice
} }
class AnthropicMessage { class AnthropicMessage {
@ -854,7 +850,7 @@ classDiagram
<<abstract>> <<abstract>>
+prepare(request, engine) Tuple[str, GenContext, List[str]] +prepare(request, engine) Tuple[str, GenContext, List[str]]
+format_stream_start(ctx) List[str] +format_stream_start(ctx) List[str]
+format_chunk(token) List[str] +format_chunk(token) str
+format_stream_end(ctx, stop) List[str] +format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict +format_response(ctx, content, stop) Dict
} }
@ -862,7 +858,7 @@ classDiagram
class OpenAIResponseBuilder { class OpenAIResponseBuilder {
+prepare(request, engine) Tuple +prepare(request, engine) Tuple
+format_stream_start(ctx) List[str] +format_stream_start(ctx) List[str]
+format_chunk(token) List[str] +format_chunk(token) str
+format_stream_end(ctx, stop) List[str] +format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict +format_response(ctx, content, stop) Dict
} }
@ -870,7 +866,7 @@ classDiagram
class AnthropicResponseBuilder { class AnthropicResponseBuilder {
+prepare(request, engine) Tuple +prepare(request, engine) Tuple
+format_stream_start(ctx) List[str] +format_stream_start(ctx) List[str]
+format_chunk(token) List[str] +format_chunk(token) str
+format_stream_end(ctx, stop) List[str] +format_stream_end(ctx, stop) List[str]
+format_response(ctx, content, stop) Dict +format_response(ctx, content, stop) Dict
} }
@ -1175,10 +1171,10 @@ classDiagram
| **astrai.serialization** | Checkpoint | Model serialization | | **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.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.tokenize** | AutoTokenizer, ChatTemplate | Tokenizer and chat template |
| **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategyGRPOStrategy, StrategyFactory, BaseSchedulerWSDScheduler, SchedulerFactory, TrainCallback(Protocol)ValidationCallback, CallbackFactory | Training workflow | | **astrai.trainer** | Trainer, TrainContext, TrainContextBuilder, BaseStrategyGRPOStrategy, StrategyFactory, BaseSchedulerWSDScheduler, 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.inference** | InferenceEngine, InferenceScheduler, Executor, KVCacheKvcacheView, 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.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** | BaseFactory | Component registration | | **astrai.factory** | Registry, BaseFactory[T] | Component registration |
| **astrai.protocols** | OptimizerProtocol, SchedulerProtocol | Structural subtyping for optimizer/scheduler wrappers | | **astrai.protocols** | OptimizerProtocol, SchedulerProtocol | Structural subtyping for optimizer/scheduler wrappers |
## Design Patterns ## Design Patterns

View File

@ -46,10 +46,10 @@ The output `meta.json` records the storage format, key names, dtype, total token
### Format Detection ### Format Detection
`detect_format(load_path)` inspects the path: `detect_format(load_path)` inspects the directory:
- If `load_path` is a file: checks suffix — `.h5`/`.hdf5` → `"h5"`, unknown suffix raises `ValueError` - If `*.h5` files exist → `"h5"` (HDF5 backend)
- If `load_path` is a directory: recursively globs for `*.h5`/`*.hdf5` files → `"h5"`, or `*.bin` + `**/meta.json` → `"bin"` - If `*.bin` + `meta.json` files exist `"bin"` (memory-mapped backend)
### Store Backends ### Store Backends
@ -83,7 +83,7 @@ DatasetFactory.load(train_type, load_path, window_size, stride=None, storage_typ
→ detect_format(load_path) → detect_format(load_path)
→ StoreFactory.create(storage_type) → StoreFactory.create(storage_type)
→ Store.load(load_path) → Store.load(load_path)
_normalize(raw) # base Store, shared by both backends H5Store._normalize() / MmapStore._normalize()
→ Store._data[Dict[str, List[Tensor]]] + _cum[Dict[str, List[int]]] → Store._data[Dict[str, List[Tensor]]] + _cum[Dict[str, List[int]]]
→ BaseDataset.__getitem__(idx) → BaseDataset.__getitem__(idx)
→ get_index(idx) → [begin, end) → get_index(idx) → [begin, end)

View File

@ -23,7 +23,7 @@ RoPE is applied **before** KV cache write, not after — otherwise position enco
## KVCache System ## KVCache System
Seven classes working together: Six classes (plus two helpers) working together:
``` ```
KVCache (facade) KVCache (facade)
@ -152,13 +152,12 @@ Supports `stop_sequences` and streaming via `event: content_block_delta`.
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai", data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai",
"choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} "choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":0,"model":"astrai", data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...,
"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} "choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","created":...,"model":"astrai", data: {"id":"chatcmpl-...","object":"chat.completion.chunk",...,
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} "choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}
data: {"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}
data: [DONE] data: [DONE]
``` ```
@ -168,7 +167,7 @@ data: [DONE]
``` ```
event: message_start event: message_start
data: {"type":"message_start","message":{"id":"msg_...","model":"astrai","role":"assistant", data: {"type":"message_start","message":{"id":"msg_...","model":"astrai","role":"assistant",
"content":[],"usage":{"input_tokens":0}}} "content":[],"stop_reason":null,...}}
event: content_block_start event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
@ -180,7 +179,7 @@ event: content_block_stop
data: {"type":"content_block_stop","index":0} data: {"type":"content_block_stop","index":0}
event: message_delta event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{...}} data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{...}}
event: message_stop event: message_stop
data: {"type":"message_stop"} data: {"type":"message_stop"}
@ -188,20 +187,26 @@ data: {"type":"message_stop"}
### Error Responses ### Error Responses
The server returns standard HTTP status codes. Pydantic validation errors (e.g. missing required fields) All endpoints use standard HTTP status codes:
are handled automatically by FastAPI with 422 status. The only application-level error is engine initialization:
| Status | Meaning | | Status | Meaning |
|--------|---------| |--------|---------|
| 200 | Success | | 200 | Success |
| 400 | Invalid request (bad JSON, missing fields, validation error) |
| 405 | Method not allowed |
| 422 | Unprocessable entity (Pydantic validation) | | 422 | Unprocessable entity (Pydantic validation) |
| 500 | Internal server error (model crash, OOM, scheduler failure) |
| 503 | Service unavailable (model not loaded, engine not ready) | | 503 | Service unavailable (model not loaded, engine not ready) |
Error response body (503): Error response body:
```json ```json
{ {
"detail": "Engine not initialized" "error": {
"message": "Invalid request: max_tokens must be > 0",
"type": "invalid_request_error",
"code": 400
}
} }
``` ```
@ -215,13 +220,16 @@ Response:
```json ```json
{ {
"total_tasks": 128, "active_requests": 3,
"total_tokens": 10240, "waiting_requests": 2,
"active_tasks": 3, "total_requests": 128,
"waiting_queue": 2 "cache_usage": 0.45,
"tokens_generated": 10240
} }
``` ```
`cache_usage` is the fraction of KV cache pages currently in use (0.01.0).
## Engine API ## Engine API
```python ```python

View File

@ -53,7 +53,7 @@
| `--ckpt_interval` | Iterations between checkpoints | 5000 | | `--ckpt_interval` | Iterations between checkpoints | 5000 |
| `--ckpt_dir` | Checkpoint save directory | checkpoint | | `--ckpt_dir` | Checkpoint save directory | checkpoint |
| `--start_epoch` | Resume from epoch (0 = from scratch) | 0 | | `--start_epoch` | Resume from epoch (0 = from scratch) | 0 |
| `--start_samples` | Resume from sample count per rank | 0 | | `--start_batch` | Resume from batch iteration | 0 |
### Validation ### Validation
@ -67,8 +67,8 @@
| Parameter | Description | Default | | Parameter | Description | Default |
|-----------|-------------|---------| |-----------|-------------|---------|
| `--log_dir` | Directory for metric logs | checkpoint/logs | | `--log_dir` | Directory for metric logs | checkpoint/logs |
| `--log_interval` | Number of optimizer steps between metric logs | 1 | | `--log_interval` | Number of batch iterations between metric logs | 100 |
| `--metrics` | Metrics to log (e.g. --metrics loss lr val_loss) | ["loss", "lr", "grad_norm"] | | `--metrics` | Metrics to log (e.g. --metrics loss lr val_loss) | ["loss", "lr"] |
### Gradient Checkpointing ### Gradient Checkpointing
@ -100,17 +100,6 @@
| `--grpo_sync_interval` | GRPO ref_model sync interval (steps) | 200 | `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` | | `--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) |
| `--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 ### Usage Example
```bash ```bash
@ -189,7 +178,7 @@ python scripts/tools/generate.py \
| `input_files` | path(s) | required | Input JSONL file(s), supports glob (`data/*.jsonl`) | | `input_files` | path(s) | required | Input JSONL file(s), supports glob (`data/*.jsonl`) |
| `--output_dir`, `-o` | path | required | Output directory for processed data | | `--output_dir`, `-o` | path | required | Output directory for processed data |
| `--config`, `-c` | path | required | Preprocessing pipeline config (JSON) | | `--config`, `-c` | path | required | Preprocessing pipeline config (JSON) |
| `--tokenizer_path` | str | `params` | Path to tokenizer directory | | `--num_workers` | int | `4` | Number of parallel workers |
Usage: Usage:
```bash ```bash

View File

@ -26,9 +26,8 @@ A single config file captures the entire pipeline, reusable and version-controll
```json ```json
{ {
"version": 1,
"input": {}, // sections (single) or sources (multi) "input": {}, // sections (single) or sources (multi)
"mask": {}, // role -> "train" | "mask" "mask": {}, // role "train" | "mask"
"mask_default": "mask", "mask_default": "mask",
"preprocessing": {}, "preprocessing": {},
"output": {} "output": {}
@ -221,12 +220,11 @@ Config:
} }
``` ```
Output keys: `prompts`, `prompts_mask`, `responses`, `masks`, `rewards` (float32) Output keys: `prompts`, `responses`, `masks`, `rewards` (float32)
- `action: "value"` — extract raw values from JSONL without tokenisation - `action: "value"` — extract raw values from JSONL without tokenisation
- `list_field: true` — tokenise each list element independently, then concatenate - `list_field: true` — tokenise each list element independently, then concatenate
- `mask_key: "masks"` — rename the auto-generated mask key (default: `responses_mask`) - `mask_key: "masks"` — rename the auto-generated mask key (default: `responses_mask`)
- `prompts_mask` is auto-generated (all masked) and unused by GRPOStrategy
--- ---
@ -276,11 +274,12 @@ When `sources` is set, `sections` is ignored.
### Template mode (`template: true`) ### Template mode (`template: true`)
For each message in the field's array:
1. Prepend BOS token (masked) 1. Prepend BOS token (masked)
2. For each message in the field's array: 2. Render through `chat_template` for that single message
1. Render through `chat_template` for that single message 3. Encode rendered text
2. Encode rendered text 4. Apply mask rule for the message's role
3. Apply mask rule for the message's role
### Non-template mode ### Non-template mode
@ -288,7 +287,7 @@ Encode the field value as text. Mask value is 1 (train) or 0 (mask) per the sect
### Text config detection ### Text config detection
When no section uses `template` and all sections have `action: "train"`, the builder omits `loss_mask` from the output — all tokens are trained. When no section uses `template` and all sections have `action: "train"`, the builder skips mask generation entirely — all tokens are trained.
--- ---
@ -299,15 +298,13 @@ When no section uses `template` and all sections have `action: "train"`, the bui
``` ```
output/ output/
__default__/ __default__/
shard_0000/ meta.json
meta.json sequence.bin
sequence.bin loss_mask.bin
loss_mask.bin
wiki/ wiki/
shard_0000/ meta.json
meta.json sequence.bin
sequence.bin loss_mask.bin
loss_mask.bin
``` ```
### Multi-Shard (`bin`) ### Multi-Shard (`bin`)
@ -327,7 +324,7 @@ output/
loss_mask.bin loss_mask.bin
``` ```
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. `MmapStore` discovers all shards under the domain directory via `rglob("meta.json")`.
--- ---
@ -352,7 +349,7 @@ python scripts/tools/preprocess.py data/grpo/*.jsonl -o output/grpo/ -c configs/
from astrai.preprocessing.pipeline import Pipeline from astrai.preprocessing.pipeline import Pipeline
from astrai.config.preprocess_config import PipelineConfig from astrai.config.preprocess_config import PipelineConfig
config = PipelineConfig.from_file("sft.json") config = PipelineConfig.from_json("sft.json")
Pipeline( Pipeline(
config, config,
["data_part1.jsonl", "data_part2.jsonl"], ["data_part1.jsonl", "data_part2.jsonl"],

View File

@ -58,9 +58,7 @@ on_train_begin
context.loss = loss.item() context.loss = loss.item()
stand_loss = loss / executor.grad_accum_steps stand_loss = loss / executor.grad_accum_steps
executor.backward(stand_loss) executor.backward(stand_loss)
context.consumed_samples += ( context.iteration += 1
context.config.batch_per_device * context.world_size
)
on_batch_end on_batch_end
if executor.sync_gradients: if executor.sync_gradients:
@ -80,13 +78,13 @@ on_train_end
| `on_train_begin` | Before training starts | `GradientCheckpointingCallback` | | `on_train_begin` | Before training starts | `GradientCheckpointingCallback` |
| `on_epoch_begin` | Start of each epoch | `ProgressBarCallback` | | `on_epoch_begin` | Start of each epoch | `ProgressBarCallback` |
| `on_batch_begin` | Every batch | — | | `on_batch_begin` | Every batch | — |
| `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `MetricLoggerCallback`, `ValidationCallback` | | `on_optimizer_step` | Every accumulation window | `GradientClippingCallback`, `ValidationCallback` |
| `on_batch_end` | Every batch | `CheckpointCallback`, `MetricLoggerCallback`, `ProgressBarCallback` | | `on_batch_end` | Every batch | `CheckpointCallback`, `MetricLoggerCallback`, `ProgressBarCallback` |
| `on_epoch_end` | End of each epoch | `ProgressBarCallback` | | `on_epoch_end` | End of each epoch | `ProgressBarCallback` |
| `on_error` | On exception during training | `CheckpointCallback`, `MetricLoggerCallback` | | `on_error` | On exception during training | `CheckpointCallback`, `MetricLoggerCallback` |
| `on_train_end` | Training ends (always via finally) | `CheckpointCallback`, `MetricLoggerCallback`, `GradientCheckpointingCallback` | | `on_train_end` | Training ends (always via finally) | `CheckpointCallback`, `MetricLoggerCallback`, `GradientCheckpointingCallback` |
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `validation` (periodic validation on val_dataset), `metric_logger` (JSONL, rank-0), `progress_bar` (tqdm), `gradient_clipping`. 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).
## Strategies ## Strategies
@ -160,8 +158,8 @@ Callback wraps each `DecoderBlock.forward` with `torch.utils.checkpoint.checkpoi
## Checkpoint ## Checkpoint
``` ```
Checkpoint(state_dict, epoch, consumed_samples, extra, meta, config) Checkpoint(state_dict, epoch, iteration, 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) ├── save(save_dir) rank-0 only: meta.json (epoch/iteration/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 └── load(save_dir, broadcast=False) loads from local disk; set broadcast=True to broadcast metadata from rank-0
``` ```

View File

@ -47,18 +47,14 @@ class TrainConfig(BaseConfig):
# checkpoint setting # checkpoint setting
start_epoch: int = field(default=0, metadata={"help": "Start epoch for training."}) start_epoch: int = field(default=0, metadata={"help": "Start epoch for training."})
start_samples: int = field( start_batch: int = field(
default=0, default=0, metadata={"help": "Start batch iteration for training."}
metadata={
"help": "Start samples count (per rank). Superseded by checkpoint consumed_samples."
},
) )
ckpt_dir: str = field( ckpt_dir: str = field(
default="./checkpoint", metadata={"help": "Checkpoint directory."} default="./checkpoint", metadata={"help": "Checkpoint directory."}
) )
ckpt_interval: int = field( ckpt_interval: int = field(
default=5000, default=5000, metadata={"help": "Number of iterations between checkpoints."}
metadata={"help": "Number of optimizer steps between checkpoints."},
) )
# lora setting # lora setting
@ -71,8 +67,12 @@ class TrainConfig(BaseConfig):
log_dir: str = field( log_dir: str = field(
default="./checkpoint/logs", metadata={"help": "Directory for metric logs."} 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( metrics: List[str] = field(
default_factory=lambda: ["loss", "lr", "grad_norm"], default_factory=lambda: ["loss", "lr"],
metadata={"help": "Metrics to record during training."}, metadata={"help": "Metrics to record during training."},
) )

View File

@ -5,13 +5,10 @@ from astrai.dataset.dataset import (
from astrai.dataset.sampler import ResumableDistributedSampler from astrai.dataset.sampler import ResumableDistributedSampler
from astrai.dataset.storage import ( from astrai.dataset.storage import (
H5Store, H5Store,
JsonlStore,
MmapStore, MmapStore,
Store, Store,
StoreFactory, StoreFactory,
detect_format, detect_format,
)
from astrai.serialization import (
load_bin, load_bin,
load_h5, load_h5,
save_bin, save_bin,
@ -25,7 +22,6 @@ __all__ = [
"StoreFactory", "StoreFactory",
"H5Store", "H5Store",
"MmapStore", "MmapStore",
"JsonlStore",
"detect_format", "detect_format",
"save_h5", "save_h5",
"load_h5", "load_h5",

View File

@ -48,26 +48,24 @@ class BaseDataset(Dataset, ABC):
f"Missing: {missing}" f"Missing: {missing}"
) )
def load(self, load_path: str, storage_type: Optional[str] = None, **kwargs): def load(self, load_path: str, storage_type: Optional[str] = None):
"""Load dataset from the given path. """Load dataset from the given path.
Auto-detects the storage format if not specified. Auto-detects the storage format if not specified.
Args: Args:
load_path: Path to the data directory or file load_path: Path to the data directory or file
storage_type: Force a specific storage type ("h5", "bin", "jsonl"), storage_type: Force a specific storage type ("h5", "bin"),
or None for auto-detection or None for auto-detection
**kwargs: Extra arguments forwarded to the store constructor and
to ``store.load()``.
Raises: Raises:
KeyError: If the loaded storage is missing required keys. KeyError: If the loaded storage is missing required keys.
""" """
if storage_type is None: if storage_type is None:
storage_type = detect_format(load_path) storage_type = detect_format(load_path)
self.storage = StoreFactory.create(storage_type, **kwargs) self.storage = StoreFactory.create(storage_type)
self._load_path = load_path self._load_path = load_path
self.storage.load(load_path, **kwargs) self.storage.load(load_path)
self._validate_keys() self._validate_keys()
@property @property
@ -146,7 +144,6 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
window_size: int, window_size: int,
stride: Optional[int] = None, stride: Optional[int] = None,
storage_type: Optional[str] = None, storage_type: Optional[str] = None,
**kwargs,
) -> "BaseDataset": ) -> "BaseDataset":
"""Create and load a dataset in one step. """Create and load a dataset in one step.
@ -155,8 +152,7 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
load_path: Path to the data file load_path: Path to the data file
window_size: Window size for data sampling window_size: Window size for data sampling
stride: Stride between consecutive samples (default: same as window_size) stride: Stride between consecutive samples (default: same as window_size)
storage_type: Storage type ("h5", "bin", "jsonl") or None for auto-detection storage_type: Storage type ("h5", "bin") or None for auto-detection
**kwargs: Extra arguments forwarded to ``dataset.load()``.
Returns: Returns:
Loaded dataset instance Loaded dataset instance
@ -165,7 +161,7 @@ class DatasetFactory(BaseFactory["BaseDataset"]):
stride = window_size stride = window_size
dataset = cls.create(train_type, window_size, stride) dataset = cls.create(train_type, window_size, stride)
dataset.load(load_path, storage_type=storage_type, **kwargs) dataset.load(load_path, storage_type=storage_type)
return dataset return dataset

View File

@ -74,7 +74,6 @@ class ResumableDistributedSampler(Sampler[int]):
self.epoch += 1 self.epoch += 1
self._indices = None self._indices = None
self.iter = self.iter % self.num_samples_per_replica
@property @property
def _remaining(self): def _remaining(self):

View File

@ -14,31 +14,85 @@ Key properties:
- Explicit length: _length = min(total elements across keys), set at load, - Explicit length: _length = min(total elements across keys), set at load,
__len__ returns O(1) __len__ returns O(1)
- Zero-copy mmap: MmapStore wraps np.memmap(mode="r"), all DataLoader - Zero-copy mmap: MmapStore wraps np.memmap(mode="r"), all DataLoader
workers share OS page-cache pages workers share OS page-cache pages
""" """
import bisect import bisect
import glob import glob
import json import json
import logging import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Dict, List, Union from typing import Dict, List, Union
import h5py
import numpy as np
import torch import torch
from torch import Tensor from torch import Tensor
from astrai.config.preprocess_config import PipelineConfig
from astrai.factory import BaseFactory 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
logger = logging.getLogger(__name__)
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
def detect_format(load_path: str) -> str: def detect_format(load_path: str) -> str:
@ -48,7 +102,7 @@ def detect_format(load_path: str) -> str:
load_path: Directory or file path load_path: Directory or file path
Returns: Returns:
Format string ("h5", "bin", or "jsonl") Format string ("h5" or "bin")
Raises: Raises:
FileNotFoundError: If no supported data files are found FileNotFoundError: If no supported data files are found
@ -58,8 +112,6 @@ def detect_format(load_path: str) -> str:
suffix = root.suffix.lower() suffix = root.suffix.lower()
if suffix in (".h5", ".hdf5"): if suffix in (".h5", ".hdf5"):
return "h5" return "h5"
if suffix == ".jsonl":
return "jsonl"
raise ValueError(f"Unsupported file format: {suffix}") raise ValueError(f"Unsupported file format: {suffix}")
h5_files = [ h5_files = [
@ -76,11 +128,6 @@ def detect_format(load_path: str) -> str:
) > 0 ) > 0
if has_meta: if has_meta:
return "bin" 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}") raise FileNotFoundError(f"No supported data files found at {load_path}")
@ -217,96 +264,3 @@ class MmapStore(Store):
self._normalize(all_raw) self._normalize(all_raw)
for tensors in self._data.values(): for tensors in self._data.values():
self._mmap_refs.extend(tensors) 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

View File

@ -37,7 +37,7 @@ def _resolve_type(
ns = vars(mod) ns = vars(mod)
if isinstance(arg, ForwardRef): if isinstance(arg, ForwardRef):
return arg._evaluate(ns, None, recursive_guard=frozenset()) return arg._evaluate(ns, None, frozenset(), recursive_guard=frozenset())
return ns.get(name) return ns.get(name)

View File

@ -132,12 +132,6 @@ class BaseExecutor:
def grad_accum_steps(self) -> int: def grad_accum_steps(self) -> int:
return self.gradient_state.num_steps 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]): class ExecutorFactory(BaseFactory[BaseExecutor]):
pass pass
@ -266,14 +260,6 @@ class FSDPExecutor(BaseExecutor):
return model.no_sync() return model.no_sync()
return contextlib.nullcontext() 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): def unwrap_model(self, model: nn.Module):
if isinstance(model, FSDP) and self.use_distributed: if isinstance(model, FSDP) and self.use_distributed:
with FSDP.state_dict_type( with FSDP.state_dict_type(

View File

@ -14,8 +14,8 @@ from typing import Dict, List
import torch import torch
from astrai.dataset.storage import save_bin, save_h5
from astrai.factory import BaseFactory from astrai.factory import BaseFactory
from astrai.serialization import save_bin, save_h5
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,8 +1,5 @@
"""Model checkpoint serialization helpers."""
import io import io
import json import json
import os
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -139,7 +136,7 @@ def load_state_dict(path: Union[str, Path], broadcast: bool = False) -> dict:
class Checkpoint: class Checkpoint:
state_dict: Dict[str, Any] = field(default_factory=dict) state_dict: Dict[str, Any] = field(default_factory=dict)
epoch: int = 0 epoch: int = 0
consumed_samples: int = 0 iteration: int = 0
extra: Dict[str, Any] = field(default_factory=dict) extra: Dict[str, Any] = field(default_factory=dict)
meta: Dict[str, Any] = field(default_factory=dict) meta: Dict[str, Any] = field(default_factory=dict)
config: Dict[str, Any] = field(default_factory=dict) config: Dict[str, Any] = field(default_factory=dict)
@ -153,7 +150,7 @@ class Checkpoint:
meta = { meta = {
"epoch": self.epoch, "epoch": self.epoch,
"consumed_samples": self.consumed_samples, "iteration": self.iteration,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
**self.meta, **self.meta,
} }
@ -179,7 +176,7 @@ class Checkpoint:
return cls( return cls(
state_dict=state_dict, state_dict=state_dict,
epoch=meta.get("epoch", 0), epoch=meta.get("epoch", 0),
consumed_samples=meta.get("consumed_samples", 0), iteration=meta.get("iteration", 0),
extra=extra, extra=extra,
config=config, config=config,
) )

View File

@ -1,43 +0,0 @@
"""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",
]

View File

@ -1,73 +0,0 @@
"""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)
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

View File

@ -1,25 +1,42 @@
from typing import Dict from typing import Any, Callable, Dict
import torch import torch
import torch.nn as nn import torch.nn as nn
def grad_norm(model: nn.Module, per_param: bool = False) -> float | Dict[str, float]: def _grad_stat(
grads = [p.grad.detach() for p in model.parameters() if p.grad is not None] model: nn.Module, fn: Callable[[torch.Tensor], Any], default: Any
if not grads: ) -> dict:
return 0.0 results = {}
for name, param in model.named_parameters():
results[name] = default
if param.grad is not None:
results[name] = fn(param.grad.data)
return results
total_sq = torch.stack([g.pow(2).sum() for g in grads]).sum()
if per_param: def grad_norm(model: nn.Module, norm_type: int = 2) -> Dict[str, float]:
norms = {} return _grad_stat(model, lambda g: g.norm(norm_type).item(), 0.0)
for name, param in model.named_parameters():
if param.grad is not None:
norms[name] = param.grad.norm(2).item() def grad_std(model: nn.Module) -> Dict[str, float]:
else: return _grad_stat(model, lambda g: g.std().item(), 0.0)
norms[name] = 0.0
norms["total"] = total_sq.sqrt().item()
return norms def grad_max(model: nn.Module) -> Dict[str, float]:
return total_sq.sqrt().item() 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)
def ctx_get_loss(ctx): def ctx_get_loss(ctx):
@ -35,4 +52,24 @@ def ctx_get_val_loss(ctx):
def ctx_get_grad_norm(ctx): def ctx_get_grad_norm(ctx):
return ctx.grad_norm 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)

View File

@ -53,7 +53,7 @@ class CosineScheduler(BaseScheduler):
optimizer, optimizer,
warmup_steps: int, warmup_steps: int,
lr_decay_steps: int, lr_decay_steps: int,
min_rate: float = 0.01, min_rate: float = 0.05,
last_epoch: int = -1, last_epoch: int = -1,
): ):
self.warmup_steps = warmup_steps self.warmup_steps = warmup_steps
@ -65,15 +65,11 @@ class CosineScheduler(BaseScheduler):
def get_lr(self) -> List[float]: def get_lr(self) -> List[float]:
# warmup # warmup
if self.last_epoch < self.warmup_steps: if self.last_epoch < self.warmup_steps:
warmup_factor = max( warmup_factor = max(self.min_rate, self.last_epoch / self.warmup_steps)
self.min_rate, self.last_epoch / max(self.warmup_steps, 1)
)
return [base_lr * warmup_factor for base_lr in self.base_lrs] return [base_lr * warmup_factor for base_lr in self.base_lrs]
# cosine decay # cosine decay
decay_progress = (self.last_epoch - self.warmup_steps) / max( decay_progress = (self.last_epoch - self.warmup_steps) / self.lr_decay_steps
self.lr_decay_steps, 1
)
decay_progress = min(decay_progress, 1.0) decay_progress = min(decay_progress, 1.0)
cosine_decay = 0.5 * (1.0 + math.cos(math.pi * decay_progress)) cosine_decay = 0.5 * (1.0 + math.cos(math.pi * decay_progress))
decay_factor = max(self.min_rate, cosine_decay) decay_factor = max(self.min_rate, cosine_decay)
@ -108,7 +104,7 @@ class SGDRScheduler(BaseScheduler):
optimizer, optimizer,
warmup_steps: int, warmup_steps: int,
cycle_length: int, cycle_length: int,
min_rate: float = 0.01, min_rate: float = 0.05,
t_mult: int = 2, t_mult: int = 2,
last_epoch: int = -1, last_epoch: int = -1,
): ):
@ -122,9 +118,7 @@ class SGDRScheduler(BaseScheduler):
def get_lr(self): def get_lr(self):
# warmup # warmup
if self.last_epoch < self.warmup_steps: if self.last_epoch < self.warmup_steps:
warmup_factor = max( warmup_factor = max(self.min_rate, self.last_epoch / self.warmup_steps)
self.min_rate, self.last_epoch / max(self.warmup_steps, 1)
)
return [base_lr * warmup_factor for base_lr in self.base_lrs] return [base_lr * warmup_factor for base_lr in self.base_lrs]
# SGDR # SGDR
@ -188,7 +182,7 @@ class WSDScheduler(BaseScheduler):
warmup_steps: int, warmup_steps: int,
stable_steps: int, stable_steps: int,
decay_steps: int, decay_steps: int,
min_rate: float = 0.01, min_rate: float = 0.0,
last_epoch: int = -1, last_epoch: int = -1,
): ):
self.warmup_steps = warmup_steps self.warmup_steps = warmup_steps
@ -200,7 +194,7 @@ class WSDScheduler(BaseScheduler):
def get_lr(self) -> List[float]: def get_lr(self) -> List[float]:
if self.last_epoch < self.warmup_steps: if self.last_epoch < self.warmup_steps:
factor = max(self.min_rate, self.last_epoch / max(self.warmup_steps, 1)) factor = self.last_epoch / max(self.warmup_steps, 1)
return [base_lr * factor for base_lr in self.base_lrs] return [base_lr * factor for base_lr in self.base_lrs]
offset = self.last_epoch - self.warmup_steps offset = self.last_epoch - self.warmup_steps

View File

@ -196,7 +196,7 @@ class SFTStrategy(BaseStrategy):
ignore_index = -100 ignore_index = -100
input_mask = make_doc_boundary_mask(position_ids) input_mask = make_doc_boundary_mask(position_ids)
target_ids = target_ids.masked_fill(~loss_mask, ignore_index) target_ids = target_ids.masked_fill(loss_mask == 0, ignore_index)
logits = self.model( logits = self.model(
input_ids=input_ids, position_ids=position_ids, input_mask=input_mask input_ids=input_ids, position_ids=position_ids, input_mask=input_mask
)["logits"] )["logits"]

View File

@ -9,6 +9,7 @@ from typing import IO, Callable, List, Optional, Protocol, runtime_checkable
import torch import torch
import torch.distributed as dist import torch.distributed as dist
import torch.nn as nn import torch.nn as nn
from torch.nn.utils import clip_grad_norm_
from torch.utils.checkpoint import checkpoint as torch_checkpoint from torch.utils.checkpoint import checkpoint as torch_checkpoint
from tqdm import tqdm from tqdm import tqdm
@ -17,7 +18,12 @@ from astrai.parallel import only_on_rank
from astrai.parallel.setup import get_current_device, get_rank from astrai.parallel.setup import get_current_device, get_rank
from astrai.serialization import Checkpoint from astrai.serialization import Checkpoint
from astrai.trainer.metric_util import ( 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_norm,
ctx_get_grad_std,
ctx_get_loss, ctx_get_loss,
ctx_get_lr, ctx_get_lr,
ctx_get_val_loss, ctx_get_val_loss,
@ -80,9 +86,7 @@ class GradientClippingCallback(TrainCallback):
self.max_grad_norm = max_grad_norm self.max_grad_norm = max_grad_norm
def on_optimizer_step(self, context: TrainContext): def on_optimizer_step(self, context: TrainContext):
context.grad_norm = context.executor.clip_grad_norm( clip_grad_norm_(context.model.parameters(), self.max_grad_norm)
context.model, self.max_grad_norm
)
@CallbackFactory.register("gradient_checkpointing") @CallbackFactory.register("gradient_checkpointing")
@ -139,35 +143,34 @@ class CheckpointCallback(TrainCallback):
self.interval = interval self.interval = interval
self.weight_only = weight_only self.weight_only = weight_only
self.save_extra_fn = save_extra_fn or CheckpointCallback.save_extra self.save_extra_fn = save_extra_fn or CheckpointCallback.save_extra
self.last_ckpt_step = 0 self.last_ckpt_iter = 0
def _save_checkpoint(self, context: TrainContext): def _save_checkpoint(self, context: TrainContext):
state_dict = context.executor.unwrap_model(context.model) state_dict = context.executor.unwrap_model(context.model)
self.last_ckpt_step = context.optimizer_step self.last_ckpt_iter = context.iteration
if get_rank() == 0: if get_rank() == 0:
save_path = os.path.join( save_path = os.path.join(
self.save_dir, self.save_dir, f"epoch_{context.epoch}_iter_{context.iteration}"
f"epoch_{context.epoch}_step_{context.optimizer_step}",
) )
extra = self.save_extra_fn(context) extra = self.save_extra_fn(context)
meta = context.config.to_dict() meta = context.config.to_dict()
context.checkpoint = Checkpoint( context.checkpoint = Checkpoint(
state_dict=state_dict, state_dict=state_dict,
epoch=context.epoch, epoch=context.epoch,
consumed_samples=context.consumed_samples, iteration=context.iteration,
config=context.model_config,
extra=extra, extra=extra,
meta=meta, meta=meta,
config=context.model_config,
) )
context.checkpoint.save(save_path) context.checkpoint.save(save_path)
def on_batch_end(self, context: TrainContext): def on_batch_end(self, context: TrainContext):
if context.optimizer_step - self.last_ckpt_step >= self.interval: if context.iteration - self.last_ckpt_iter >= self.interval:
self._save_checkpoint(context) self._save_checkpoint(context)
def on_train_end(self, context: TrainContext): def on_train_end(self, context: TrainContext):
if context.optimizer_step != self.last_ckpt_step: if context.iteration != self.last_ckpt_iter:
self._save_checkpoint(context) self._save_checkpoint(context)
def on_error(self, context: TrainContext): def on_error(self, context: TrainContext):
@ -199,27 +202,23 @@ class ProgressBarCallback(TrainCallback):
@only_on_rank(0) @only_on_rank(0)
def on_epoch_begin(self, context: TrainContext): def on_epoch_begin(self, context: TrainContext):
total_steps = len(context.dataloader) // context.executor.grad_accum_steps
self.progress_bar = tqdm( self.progress_bar = tqdm(
total=total_steps, context.dataloader,
desc=f"Epoch {context.epoch + 1}/{self.num_epoch}", desc=f"Epoch {context.epoch + 1}/{self.num_epoch}",
dynamic_ncols=True, dynamic_ncols=True,
file=self.file or sys.stdout, file=self.file or sys.stdout,
) )
@only_on_rank(0) @only_on_rank(0)
def on_optimizer_step(self, context: TrainContext): def on_batch_end(self, context: TrainContext):
self.progress_bar.update(1)
postfix = { postfix = {
"step": context.optimizer_step,
"loss": f"{context.loss:.4f}", "loss": f"{context.loss:.4f}",
"lr": f"{context.optimizer.param_groups[-1]['lr']:.2e}", "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: if context.val_loss is not None:
postfix["val_loss"] = f"{context.val_loss:.4f}" postfix["val_loss"] = f"{context.val_loss:.4f}"
self.progress_bar.set_postfix(postfix) self.progress_bar.set_postfix(postfix)
self.progress_bar.update(1)
@only_on_rank(0) @only_on_rank(0)
def on_epoch_end(self, context: TrainContext): def on_epoch_end(self, context: TrainContext):
@ -228,20 +227,20 @@ class ProgressBarCallback(TrainCallback):
self.progress_bar.close() self.progress_bar.close()
@CallbackFactory.register("metric") @CallbackFactory.register("metric_logger")
class MetricCallback(TrainCallback): class MetricLoggerCallback(TrainCallback):
def __init__( def __init__(
self, self,
log_dir: str, log_dir: str,
save_interval: int, save_interval: int,
log_interval: int = 10,
metrics: List[str] = None, metrics: List[str] = None,
val_step: int = 0,
): ):
self.last_log_flush_step = 0 self.last_log_iter = 0
self._last_val_loss = None
self.save_interval = save_interval self.save_interval = save_interval
self.log_interval = log_interval
self.metrics = metrics or ["loss", "lr"] 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 = Path(log_dir) if log_dir else Path.cwd() / "logs"
self.log_dir.mkdir(parents=True, exist_ok=True) self.log_dir.mkdir(parents=True, exist_ok=True)
@ -253,6 +252,11 @@ class MetricCallback(TrainCallback):
"lr": ctx_get_lr, "lr": ctx_get_lr,
"val_loss": ctx_get_val_loss, "val_loss": ctx_get_val_loss,
"grad_norm": ctx_get_grad_norm, "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 _metrics(self, context: TrainContext, names): def _metrics(self, context: TrainContext, names):
@ -268,13 +272,46 @@ class MetricCallback(TrainCallback):
"type": event_type, "type": event_type,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"epoch": context.epoch, "epoch": context.epoch,
"step": context.optimizer_step, "iter": context.iteration,
"consumed_samples": context.consumed_samples,
**extra, **extra,
} }
self.log_cache.append(entry) self.log_cache.append(entry)
def _run_validation(self, context: TrainContext) -> float: @only_on_rank(0)
def _flush(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:
step_metrics = [m for m in self.metrics if m != "val_loss"]
self._append("step", context, **self._metrics(context, step_metrics))
if context.iteration - self.last_log_iter >= self.save_interval:
self._flush(context.epoch, context.iteration)
self.last_log_iter = context.iteration
def on_optimizer_step(self, context):
if context.val_loss is not None and context.val_loss != self._last_val_loss:
self._append("validation", context, val_loss=context.val_loss)
self._last_val_loss = context.val_loss
def on_epoch_end(self, context):
self._append("epoch", context)
def on_train_end(self, context):
if context.iteration != self.last_log_iter:
self._flush(context.epoch, context.iteration)
def on_error(self, context):
self._flush(context.epoch, context.iteration)
@CallbackFactory.register("validation")
class ValidationCallback(TrainCallback):
def _run_validation(self, context: TrainContext):
context.model.eval() context.model.eval()
total_loss = 0.0 total_loss = 0.0
@ -286,49 +323,27 @@ class MetricCallback(TrainCallback):
total_loss += loss.item() total_loss += loss.item()
num_batches += 1 num_batches += 1
avg_loss = total_loss / max(num_batches, 1)
if context.world_size > 1 and dist.is_initialized(): if context.world_size > 1 and dist.is_initialized():
stats = torch.tensor( loss_tensor = torch.tensor([avg_loss], device=get_current_device())
[total_loss, float(num_batches)], device=get_current_device() dist.all_reduce(loss_tensor, op=dist.ReduceOp.AVG)
) avg_loss = loss_tensor.item()
dist.all_reduce(stats, op=dist.ReduceOp.SUM)
avg_loss = (stats[0] / stats[1]).item()
else:
avg_loss = total_loss / max(num_batches, 1)
context.val_loss = avg_loss
context.model.train() context.model.train()
return avg_loss
@only_on_rank(0) step_count = context.iteration // context.config.grad_accum_steps
def _flush(self, epoch, step): logger.info(
log_file = self.log_dir / f"epoch_{epoch}_step_{step}_metric.jsonl" f"Epoch {context.epoch + 1}, Step {step_count}, Val Loss: {avg_loss:.4f}"
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): def on_optimizer_step(self, context: TrainContext):
if ( if context.val_dataloader is None:
context.val_dataloader is not None return
and self.val_step > 0 cfg = context.config
and context.optimizer_step >= self._next_val_step if cfg.val_step <= 0:
): return
context.val_loss = self._run_validation(context) step_count = context.iteration // cfg.grad_accum_steps
self._next_val_step = context.optimizer_step + self.val_step if step_count % cfg.val_step == 0:
self._append("validation", context, val_loss=context.val_loss) self._run_validation(context)
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)

View File

@ -29,9 +29,8 @@ class TrainContext:
executor: BaseExecutor = field(default=None) executor: BaseExecutor = field(default=None)
epoch: int = field(default=0) epoch: int = field(default=0)
consumed_samples: int = field(default=0) iteration: int = field(default=0)
loss: float = field(default=0.0) loss: float = field(default=0.0)
grad_norm: Optional[float] = field(default=None)
val_dataloader: Optional[DataLoader] = field(default=None) val_dataloader: Optional[DataLoader] = field(default=None)
val_loss: Optional[float] = field(default=None) val_loss: Optional[float] = field(default=None)
@ -39,14 +38,6 @@ class TrainContext:
rank: int = field(default=0) rank: int = field(default=0)
kwargs: Dict[str, Any] = field(default_factory=dict) 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: class TrainContextBuilder:
def __init__( def __init__(
@ -98,10 +89,7 @@ class TrainContextBuilder:
if checkpoint.config: if checkpoint.config:
context.model_config = checkpoint.config context.model_config = checkpoint.config
context.epoch = checkpoint.epoch or cfg.start_epoch context.epoch = checkpoint.epoch or cfg.start_epoch
if checkpoint.consumed_samples > 0: context.iteration = checkpoint.iteration or cfg.start_batch
context.consumed_samples = checkpoint.consumed_samples
else:
context.consumed_samples = cfg.start_samples * context.world_size
context.checkpoint = checkpoint context.checkpoint = checkpoint
if cfg.lora is not None: if cfg.lora is not None:
@ -127,7 +115,7 @@ class TrainContextBuilder:
cfg.dataset, [n_train, n_val], generator=generator cfg.dataset, [n_train, n_val], generator=generator
) )
sampler_offset = context.consumed_samples // context.world_size sampler_offset = context.iteration * cfg.batch_per_device
sampler = ResumableDistributedSampler( sampler = ResumableDistributedSampler(
data_source=train_dataset, data_source=train_dataset,
start_epoch=context.epoch, start_epoch=context.epoch,

View File

@ -34,12 +34,13 @@ class Trainer:
cfg.ckpt_dir, cfg.ckpt_dir,
cfg.ckpt_interval, cfg.ckpt_interval,
), ),
CallbackFactory.create("validation"),
CallbackFactory.create( CallbackFactory.create(
"metric", "metric_logger",
log_dir=cfg.log_dir, log_dir=cfg.log_dir,
save_interval=cfg.ckpt_interval, save_interval=cfg.ckpt_interval,
log_interval=cfg.log_interval,
metrics=cfg.metrics, metrics=cfg.metrics,
val_step=cfg.val_step,
), ),
CallbackFactory.create("progress_bar", cfg.n_epoch), CallbackFactory.create("progress_bar", cfg.n_epoch),
CallbackFactory.create("gradient_clipping", cfg.max_grad_norm), CallbackFactory.create("gradient_clipping", cfg.max_grad_norm),
@ -73,9 +74,7 @@ class Trainer:
context.loss = loss.item() context.loss = loss.item()
stand_loss = loss / executor.grad_accum_steps stand_loss = loss / executor.grad_accum_steps
executor.backward(stand_loss) executor.backward(stand_loss)
context.consumed_samples += ( context.iteration += 1
context.config.batch_per_device * context.world_size
)
self._call_callbacks("on_batch_end", context) self._call_callbacks("on_batch_end", context)
if executor.sync_gradients: if executor.sync_gradients:

View File

@ -1,4 +1,3 @@
from argparse import ArgumentParser
from pathlib import Path from pathlib import Path
import torch import torch
@ -8,82 +7,42 @@ from astrai.model import AutoModel
from astrai.tokenize import AutoTokenizer from astrai.tokenize import AutoTokenizer
PROJECT_ROOT = Path(__file__).resolve().parents[2] 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(): def chat():
args = parse_args() model = AutoModel.from_pretrained(PARAMETER_ROOT)
model_path = args.model_path tokenizer = AutoTokenizer.from_pretrained(PARAMETER_ROOT)
model = AutoModel.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)
model.to(device="cuda", dtype=torch.bfloat16) model.to(device="cuda", dtype=torch.bfloat16)
engine = InferenceEngine(model=model, tokenizer=tokenizer)
messages = [{"role": "system", "content": args.system_prompt}] messages = [{"role": "system", "content": "You are a helpful assistant."}]
engine = InferenceEngine(model=model, tokenizer=tokenizer)
while True: while True:
query = input(">> ") query = input(">> ")
if query == "!exit": if query == "!exit":
break break
# Add user message
messages.append({"role": "user", "content": query}) messages.append({"role": "user", "content": query})
# Generate response
full_response = "" full_response = ""
prompt = tokenizer.apply_chat_template(messages, tokenize=False) prompt = tokenizer.apply_chat_template(messages, tokenize=False)
for token in engine.generate( for token in engine.generate(
prompt=prompt, prompt=prompt,
stream=True, stream=True,
max_tokens=args.max_tokens, max_tokens=2048,
temperature=args.temperature, temperature=0.8,
top_p=args.top_p, top_p=0.95,
top_k=args.top_k, top_k=50,
): ):
print(token, end="", flush=True) print(token, end="", flush=True)
full_response += token full_response += token
print() print()
# Add assistant response to messages
messages.append({"role": "assistant", "content": full_response.strip()}) messages.append({"role": "assistant", "content": full_response.strip()})

View File

@ -1,307 +0,0 @@
"""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()

View File

@ -52,11 +52,8 @@ def compute_ifd(
def _compute_ifd_raw(model, tokenizer, instruction, response, device, max_len) -> dict: def _compute_ifd_raw(model, tokenizer, instruction, response, device, max_len) -> dict:
instr_ids = tokenizer.encode(instruction, add_special_tokens=False) instr_ids = tokenizer.encode(instruction)
resp_ids = tokenizer.encode(response, add_special_tokens=False) resp_ids = tokenizer.encode(response)
if len(resp_ids) > max_len:
resp_ids = resp_ids[:max_len]
if not resp_ids: if not resp_ids:
return { return {
@ -69,39 +66,28 @@ def _compute_ifd_raw(model, tokenizer, instruction, response, device, max_len) -
qa_len = len(instr_ids) + len(resp_ids) qa_len = len(instr_ids) + len(resp_ids)
if qa_len > max_len: if qa_len > max_len:
overflow = qa_len - max_len overflow = qa_len - max_len
if overflow >= len(instr_ids): instr_ids = instr_ids[overflow:]
resp_ids = resp_ids[:max_len]
instr_ids = []
else:
instr_ids = instr_ids[overflow:]
if not instr_ids:
return {
"L_cond": None,
"L_uncond": None,
"ifd": None,
"error": "response too long for context",
}
instr_len = len(instr_ids) instr_len = len(instr_ids)
resp_len = len(resp_ids) resp_len = len(resp_ids)
qa_ids = instr_ids + resp_ids qa_ids = instr_ids + resp_ids
qa_tensor = torch.tensor([qa_ids], device=device, dtype=torch.long)
with torch.inference_mode(): with torch.inference_mode():
logits_qa = model(torch.tensor([qa_ids], device=device, dtype=torch.long))[ logits_qa = model(qa_tensor)["logits"][0]
"logits"
][0]
logits_resp = model(torch.tensor([resp_ids], device=device, dtype=torch.long))[
"logits"
][0]
resp_logits = logits_qa[instr_len - 1 : -1] resp_logits = logits_qa[instr_len - 1 : -1]
resp_targets = logits_resp.new_tensor(resp_ids, dtype=torch.long) resp_targets = torch.tensor(resp_ids, device=device, dtype=torch.long)
L_cond = F.cross_entropy(resp_logits, resp_targets, reduction="mean").item() L_cond = F.cross_entropy(resp_logits, resp_targets, reduction="mean").item()
resp_tensor = torch.tensor([resp_ids], device=device, dtype=torch.long)
with torch.inference_mode():
logits_resp = model(resp_tensor)["logits"][0]
unp_logits = logits_resp[:-1] unp_logits = logits_resp[:-1]
unp_targets = logits_resp.new_tensor(resp_ids[1:], dtype=torch.long) unp_targets = resp_tensor[0, 1:]
L_uncond = F.cross_entropy(unp_logits, unp_targets, reduction="mean").item() L_uncond = F.cross_entropy(unp_logits, unp_targets, reduction="mean").item()
ifd = L_cond / L_uncond if L_uncond > 0 else None ifd = L_cond / L_uncond if L_uncond > 0 else None
@ -199,7 +185,7 @@ def process_file(
output_file: str, output_file: str,
instr_key: str, instr_key: str,
resp_key: str, resp_key: str,
max_len: int = 2048, max_len: int,
use_chat_template: bool = False, use_chat_template: bool = False,
): ):
device = "cuda" if torch.cuda.is_available() else "cpu" device = "cuda" if torch.cuda.is_available() else "cpu"

View File

@ -150,8 +150,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--metrics", "--metrics",
nargs="*", nargs="*",
default=["loss", "lr", "grad_norm"], default=["loss", "lr"],
help="Metrics to log (e.g. --metrics loss lr val_loss). Default: loss lr grad_norm.", help="Metrics to log (e.g. --metrics loss lr val_loss). Default: loss lr.",
) )
parser.add_argument( parser.add_argument(
"--log_dir", "--log_dir",
@ -159,6 +159,12 @@ def parse_args() -> argparse.Namespace:
default="checkpoint/logs", default="checkpoint/logs",
help="Directory for metric 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( parser.add_argument(
"--grpo_sync_interval", "--grpo_sync_interval",
type=int, type=int,
@ -169,10 +175,7 @@ def parse_args() -> argparse.Namespace:
"--start_epoch", type=int, default=0, help="Start epoch for training." "--start_epoch", type=int, default=0, help="Start epoch for training."
) )
parser.add_argument( parser.add_argument(
"--start_samples", "--start_batch", type=int, default=0, help="Start batch for training."
type=int,
default=0,
help="Start samples (per rank) for training.",
) )
parser.add_argument( parser.add_argument(
@ -266,20 +269,7 @@ def create_model(config):
def create_optimizer(model, **kwargs) -> optim.Optimizer: def create_optimizer(model, **kwargs) -> optim.Optimizer:
decay_params = [] return optim.AdamW(model.parameters(), fused=True, **kwargs)
no_decay_params = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
if param.dim() < 2 or "norm" in name or "bias" in name:
no_decay_params.append(param)
else:
decay_params.append(param)
param_groups = [
{"params": decay_params, "weight_decay": kwargs.pop("weight_decay", 0.01)},
{"params": no_decay_params, "weight_decay": 0.0},
]
return optim.AdamW(param_groups, fused=True, **kwargs)
def create_scheduler( def create_scheduler(
@ -314,7 +304,7 @@ def train(
n_epoch: int, n_epoch: int,
batch_per_device: int, batch_per_device: int,
start_epoch: int, start_epoch: int,
start_samples: int, start_batch: int,
grad_accum_steps: int, grad_accum_steps: int,
warmup_ratio: float, warmup_ratio: float,
ckpt_interval: int, ckpt_interval: int,
@ -323,6 +313,7 @@ def train(
val_step: int, val_step: int,
metrics: list[str], metrics: list[str],
log_dir: str, log_dir: str,
log_interval: int,
dpo_beta: float, dpo_beta: float,
grpo_clip_eps: float, grpo_clip_eps: float,
grpo_kl_coef: float, grpo_kl_coef: float,
@ -440,7 +431,7 @@ def train(
n_epoch=n_epoch, n_epoch=n_epoch,
batch_per_device=batch_per_device, batch_per_device=batch_per_device,
start_epoch=start_epoch, start_epoch=start_epoch,
start_samples=start_samples, start_batch=start_batch,
ckpt_interval=ckpt_interval, ckpt_interval=ckpt_interval,
grad_accum_steps=grad_accum_steps, grad_accum_steps=grad_accum_steps,
max_grad_norm=max_grad_norm, max_grad_norm=max_grad_norm,
@ -458,6 +449,7 @@ def train(
val_step=val_step, val_step=val_step,
metrics=metrics, metrics=metrics,
log_dir=log_dir, log_dir=log_dir,
log_interval=log_interval,
gradient_checkpointing_modules=grad_ckpt_modules, gradient_checkpointing_modules=grad_ckpt_modules,
executor_kwargs=executor_kwargs, executor_kwargs=executor_kwargs,
extra_kwargs=strategy_kwargs, extra_kwargs=strategy_kwargs,

View File

@ -75,7 +75,7 @@ class MultiTurnDataset(Dataset):
class EarlyStoppingDataset(Dataset): class EarlyStoppingDataset(Dataset):
"""Dataset that triggers early stopping after consuming a specified number of samples.""" """Dataset that triggers early stopping after a specified number of iterations."""
def __init__(self, length=10, stop_after=5): def __init__(self, length=10, stop_after=5):
self.length = length self.length = length

View File

@ -25,9 +25,7 @@ def test_single_process():
scheduler.step() scheduler.step()
checkpoint = Checkpoint( checkpoint = Checkpoint(state_dict=model.state_dict(), epoch=3, iteration=30)
state_dict=model.state_dict(), epoch=3, consumed_samples=120
)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
checkpoint.save(tmpdir) checkpoint.save(tmpdir)
@ -35,7 +33,7 @@ def test_single_process():
loaded_checkpoint = Checkpoint.load(tmpdir) loaded_checkpoint = Checkpoint.load(tmpdir)
assert loaded_checkpoint.epoch == 3 assert loaded_checkpoint.epoch == 3
assert loaded_checkpoint.consumed_samples == 120 assert loaded_checkpoint.iteration == 30
def test_checkpoint_with_extra(): def test_checkpoint_with_extra():
@ -48,10 +46,7 @@ def test_checkpoint_with_extra():
"scheduler": {"last_epoch": 5}, "scheduler": {"last_epoch": 5},
} }
checkpoint = Checkpoint( checkpoint = Checkpoint(
state_dict=model.state_dict(), state_dict=model.state_dict(), epoch=1, iteration=10, extra=extra
epoch=1,
consumed_samples=40,
extra=extra,
) )
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@ -82,7 +77,7 @@ def simple_training():
checkpoint = Checkpoint( checkpoint = Checkpoint(
state_dict=model.state_dict(), state_dict=model.state_dict(),
epoch=2, epoch=2,
consumed_samples=40, iteration=10,
) )
rank = get_rank() rank = get_rank()

View File

@ -1,18 +1,14 @@
import json
import os import os
import numpy as np import numpy as np
import pytest import pytest
import torch import torch
from astrai.config.preprocess_config import PipelineConfig
from astrai.dataset.dataset import DatasetFactory, SEQDataset from astrai.dataset.dataset import DatasetFactory, SEQDataset
from astrai.dataset.storage import ( from astrai.dataset.storage import (
H5Store, H5Store,
StoreFactory, StoreFactory,
detect_format, detect_format,
)
from astrai.serialization import (
load_bin, load_bin,
save_bin, save_bin,
save_h5, save_h5,
@ -23,39 +19,6 @@ def _rand_seq(length, vocab=1000):
return torch.randint(0, vocab, (length,), dtype=torch.int64) 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( def _make_seq_dataset(
test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs test_dir, name="data", seq_length=200, train_type="seq", data=None, **load_kwargs
): ):
@ -409,106 +372,3 @@ def test_dataset_load_explicit_storage_type(base_test_env):
dataset = _make_seq_dataset(test_dir, "explicit", storage_type="h5") dataset = _make_seq_dataset(test_dir, "explicit", storage_type="h5")
assert len(dataset) > 0 assert len(dataset) > 0
assert dataset.count == 200 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

View File

@ -52,7 +52,7 @@ def create_train_config(
batch_per_device: Batch size per device (default: 2) batch_per_device: Batch size per device (default: 2)
grad_accum_steps: Gradient accumulation steps (default: 1) grad_accum_steps: Gradient accumulation steps (default: 1)
max_grad_norm: Maximum gradient norm for clipping (default: 1.0) max_grad_norm: Maximum gradient norm for clipping (default: 1.0)
ckpt_interval: Checkpoint save interval in optimizer steps (default: 5) ckpt_interval: Checkpoint save interval in iterations (default: 5)
random_seed: Random seed for reproducibility (default: 42) random_seed: Random seed for reproducibility (default: 42)
**kwargs: Additional arguments passed to TrainConfig **kwargs: Additional arguments passed to TrainConfig

View File

@ -44,14 +44,14 @@ def test_early_stopping_simulation(base_test_env, early_stopping_dataset):
pass pass
# Resume from latest checkpoint # Resume from latest checkpoint
load_dir = os.path.join(base_test_env["test_dir"], "epoch_0_step_1") load_dir = os.path.join(base_test_env["test_dir"], "epoch_0_iter_2")
trainer = Trainer(train_config) trainer = Trainer(train_config)
trainer.train(resume_dir=load_dir) trainer.train(resume_dir=load_dir)
# Verify checkpoint was saved at expected step # Verify checkpoint was saved at expected iteration
load_dir = os.path.join(base_test_env["test_dir"], "epoch_1_step_5") load_dir = os.path.join(base_test_env["test_dir"], "epoch_1_iter_10")
import json import json
with open(os.path.join(load_dir, "meta.json")) as f: with open(os.path.join(load_dir, "meta.json")) as f:
meta = json.load(f) meta = json.load(f)
assert meta["consumed_samples"] == 20 assert meta["iteration"] == 10