This guide loads Qwen3-0.6B from its Hugging Face checkpoint, fine-tunes it on WikiText-2 and exports it back in the Hugging Face format. A second config trains LoRA adapters instead of all weights. Another script measures the perplexity of a model with the inference loop. The code is in example/guide/finetune_qwen3.
Hugging Face integration will change
d9d will integrate with Hugging Face more closely (#69), so this guide will change. Today, you copy the model parameters from config.json by hand and copy the config and the tokenizer files after export.
Run the Example
Install d9d and get the repository as in the Quickstart. The example also reads the data with the datasets and tokenizers packages:
The job streams the weights into the model through the state mapper that initialize_model_stage() returns (see The Model Build). The model provider also handles LoRA and export, which the sections below describe:
classQwen3Provider(ModelProvider[DecoderForCausalLM[Qwen3DenseModel]]):def__init__(self,params:Qwen3DenseParameters,lora:LoRAConfig|None):self._params=paramsself._lora=LoRA(lora)ifloraisnotNoneelseNonedefinitialize_model_stage(self,context:InitializeModelStageContext)->InitializeModelStageResult:backbone=Qwen3DenseModel(params=self._params,stage=context.stage,hidden_states_snapshot_mode=HiddenStatesAggregationMode.no,enable_checkpointing=True,)model=DecoderForCausalLM(backbone,context.stage).bfloat16()# Maps the Hugging Face checkpoint keys to the d9d model keys.state_mapper=mapper_from_huggingface_qwen3_dense_for_causal_lm(self._params)ifself._loraisnotNone:# Wraps the matching layers in LoRA adapters and freezes everything else.# The LoRA mapper then moves the original weights of the wrapped layers to their new keys.lora_mapper=inject_peft_and_freeze(self._lora,model)state_mapper=ModelStateMapperSequential([state_mapper,lora_mapper])returnInitializeModelStageResult(model=model,state_mapper=state_mapper)defparallelize_model_stage(self,context:ParallelizeModelStageContext):parallelize_qwen3_dense_model(context.dist_context,context.model.model,context.stage)ifcontext.stage.is_current_stage_last:parallelize_causal_lm_head(context.model.head,context.dist_context)defprepare_export_model_stage(self,context:PrepareExportModelStageContext)->PrepareExportModelStageResult:ifself._loraisnotNone:# Merges the adapters into the base weights, so the export has the original architecture.merge_peft(self._lora,context.model)# Exports the model in the Hugging Face format.returnPrepareExportModelStageResult(state_mapper=mapper_to_huggingface_qwen3_dense_for_causal_lm(self._params))
The provider casts the model to bf16 with .bfloat16(). The config keeps the gradients in fp32 and uses stochastic_adamw, which updates bf16 weights with stochastic rounding (see Stochastic Optimizers):
@dataclasses.dataclassclassCausalLMState:num_tokens:torch.TensorclassCausalLMTask(TrainTask[TextBatch,SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],SequenceCausalLMOutput,CausalLMState,]):defbuild_forward_inputs(self,ctx:BuildForwardInputsContext[TextBatch])->BuildForwardInputsResult[SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],CausalLMState]:labels=ctx.batch.labelsreturnBuildForwardInputsResult(input=SequenceInput(input_ids=ctx.batch.input_ids),shared=SequenceHeadShared(sequence=SequenceShared(position_ids=ctx.batch.position_ids),head=SequenceCausalLMHeadShared(labels=labels),),state=CausalLMState(num_tokens=(labels!=LM_IGNORE_INDEX).sum()),)defcompute_loss(self,ctx:ComputeLossContext[SequenceCausalLMOutput,CausalLMState])->ComputeLossResult:# The head returns the per-token cross-entropy loss. Padding positions hold zeros.num_tokens=ctx.state.num_tokensloss=ctx.pipeline_results.cross_entropy.sum()/num_tokens# Weighting each microbatch by its token count gives the mean loss per token across microbatches and ranks.returnComputeLossResult(loss=loss,loss_weight=num_tokens.float())
Returning the number of tokens as the loss_weight makes the loop average the loss per token across microbatches and ranks.
Export to Hugging Face
prepare_export_model_stage() of the provider returns mapper_to_huggingface_qwen3_dense_for_causal_lm, so Trainer.export() writes the weights with the Hugging Face keys. The export has only the .safetensors files. Copy the config and the tokenizer from the original model to load it with Transformers:
fromtransformersimportAutoModelForCausalLM,AutoTokenizertokenizer=AutoTokenizer.from_pretrained("runs/export")model=AutoModelForCausalLM.from_pretrained("runs/export",dtype="bfloat16").cuda()inputs=tokenizer("The history of the Roman Empire",return_tensors="pt").to("cuda")print(tokenizer.decode(model.generate(**inputs,max_new_tokens=25)[0]))
Qwen3-0.6B ties its input embeddings to its LM head. d9d trains them as two separate weights, so Transformers logs that it does not tie them. Set "tie_word_embeddings": false in the copied config.json to silence the message.
Fine-Tune with LoRA
config_lora.yaml adds a lora section, which trains LoRA adapters on the attention projections:
Injection: initialize_model_stage() wraps the matching layers and freezes all other weights. It chains the LoRA mapper after the Hugging Face mapper, because the wrapped layers move their weights to new keys.
Checkpoints: The config sets checkpoint_only_trainable_parameters, so the checkpoints hold only the adapters: 46 MiB instead of 7.1 GiB.
Export: prepare_export_model_stage() merges the adapters into the base weights first. The export in runs/export-lora/ has the original architecture. Copy the config and the tokenizer into it as above to load it with Transformers.
Evaluate the Model
evaluate.py computes the perplexity on the validation split with the inference loop. It takes the model state to evaluate as its second argument. It loads it through the same Hugging Face mapper, so it reads the original model and the exports alike:
Each run logs the mean loss and the perplexity on the WikiText-2 validation split.
The script reuses the dataset and the model provider of finetune.py. Its task sums the loss and the number of tokens of each microbatch and computes the perplexity at the end:
@dataclasses.dataclassclassPerplexityState:num_tokens:torch.TensorclassPerplexityTask(InferenceTask[TextBatch,SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],SequenceCausalLMOutput,PerplexityState,]):def__init__(self,dist_context:DistributedContext):self._dist_context=dist_contextself._loss_sum=torch.zeros((),dtype=torch.float64,device="cuda")self._num_tokens=torch.zeros((),dtype=torch.float64,device="cuda")defbuild_forward_inputs(self,ctx:BuildForwardInputsContext[TextBatch])->BuildForwardInputsResult[SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],PerplexityState]:labels=ctx.batch.labelsreturnBuildForwardInputsResult(input=SequenceInput(input_ids=ctx.batch.input_ids),shared=SequenceHeadShared(sequence=SequenceShared(position_ids=ctx.batch.position_ids),head=SequenceCausalLMHeadShared(labels=labels),),state=PerplexityState(num_tokens=(labels!=LM_IGNORE_INDEX).sum()),)defprocess_outputs(self,ctx:ProcessOutputsContext[SequenceCausalLMOutput,PerplexityState]):# The head returns the per-token cross-entropy loss. Padding positions hold zeros.self._loss_sum+=ctx.pipeline_results.cross_entropy.sum()self._num_tokens+=ctx.state.num_tokensdeffinalize(self,ctx:FinalizeContext)->None:# Ranks that host no last pipeline stage add zeros, so a sum over all ranks covers every batch once.ifself._dist_context.mesh_params.is_distributed:group=self._dist_context.mesh_for(FLAT_DOMAIN).get_group()dist.all_reduce(self._loss_sum,group=group)dist.all_reduce(self._num_tokens,group=group)mean_loss=(self._loss_sum/self._num_tokens).item()ifself._dist_context.is_main_process:self._dist_context.logger.info(f"Mean loss {mean_loss:.4f}, perplexity {math.exp(mean_loss):.2f}")
More GPUs
parallelize_model_stage() applies the default Qwen3 strategy: HSDP over the data-parallel dimensions. Set data_parallel_replicate or data_parallel_shard in mesh and launch with torchrun, as described in Running Jobs. See Qwen3 Dense for the supported parallelism.
importdataclassesimportsysfromcollections.abcimportSequencefrompathlibimportPathimportdatasetsimporttorchimportyamlfromd9d.core.dist_contextimportDeviceMeshParameters,DistributedContextfromd9d.datasetimportpad_stack_1dfromd9d.loop.autoimport(AutoDataConfig,AutoDataProvider,AutoLRSchedulerConfig,AutoLRSchedulerProvider,AutoOptimizerConfig,AutoOptimizerProvider,)fromd9d.loop.configimportTrainerConfigfromd9d.loop.controlimport(BuildForwardInputsContext,BuildForwardInputsResult,ComputeLossContext,ComputeLossResult,InitializeModelStageContext,InitializeModelStageResult,ModelProvider,ParallelizeModelStageContext,PrepareExportModelStageContext,PrepareExportModelStageResult,TrainTask,)fromd9d.loop.runimportTrainingConfiguratorfromd9d.model_state.mapper.composeimportModelStateMapperSequentialfromd9d.module.block.headimportLM_IGNORE_INDEX,SequenceCausalLMHeadShared,SequenceCausalLMOutputfromd9d.module.block.hidden_states_aggregatorimportHiddenStatesAggregationModefromd9d.module.modelimportDecoderForCausalLMfromd9d.module.model.ioimportSequenceHeadShared,SequenceInput,SequenceSharedfromd9d.module.model.qwen3_denseimport(Qwen3DenseModel,Qwen3DenseParameters,mapper_from_huggingface_qwen3_dense_for_causal_lm,mapper_to_huggingface_qwen3_dense_for_causal_lm,)fromd9d.module.parallelism.modelimportparallelize_causal_lm_head,parallelize_qwen3_dense_modelfromd9d.peftimportinject_peft_and_freeze,merge_peftfromd9d.peft.loraimportLoRA,LoRAConfigfrompydanticimportBaseModel,ConfigDictfromtokenizersimportTokenizerfromtorch.utils.dataimportDatasetclassDataConfig(BaseModel):model_config=ConfigDict(extra="forbid")dataset:strdataset_config:str|Nonesplit:strtext_column:strtokenizer:Pathmax_length:intclassProjectConfig(BaseModel):model_config=ConfigDict(extra="forbid")mesh:DeviceMeshParametersmodel:Qwen3DenseParameterslora:LoRAConfig|Nonedata:DataConfigauto_data:AutoDataConfigtrainer:TrainerConfigoptimizer:AutoOptimizerConfiglr_scheduler:AutoLRSchedulerConfigexport_to:Path# One microbatch of token sequences, padded to one length.@dataclasses.dataclassclassTextBatch:input_ids:torch.Tensorlabels:torch.Tensorposition_ids:torch.TensorclassTextDataset(Dataset):def__init__(self,texts:list[str],tokenizer:Tokenizer,max_length:int):self._samples=[]fortextintexts:# One more token than max_length, because the labels are the inputs shifted by one.ids=tokenizer.encode(text).ids[:max_length+1]iflen(ids)>1:self._samples.append(torch.tensor(ids,dtype=torch.long))def__getitem__(self,index:int)->torch.Tensor:returnself._samples[index]def__len__(self)->int:returnlen(self._samples)@staticmethoddefcollate(samples:Sequence[torch.Tensor])->TextBatch:# d9d models do not shift the labels, so shift them here.returnTextBatch(input_ids=pad_stack_1d([tokens[:-1]fortokensinsamples],pad_value=0),labels=pad_stack_1d([tokens[1:]fortokensinsamples],pad_value=LM_IGNORE_INDEX),position_ids=pad_stack_1d([torch.arange(len(tokens)-1)fortokensinsamples],pad_value=0),)defbuild_dataset(config:DataConfig,dist_context:DistributedContext)->Dataset:# Rank 0 downloads the dataset first. The other ranks then read it from the cache.withdist_context.main_process_first():data=datasets.load_dataset(config.dataset,config.dataset_config,split=config.split)texts=[textfortextindata[config.text_column]iftext.strip()]returnTextDataset(texts,Tokenizer.from_file(str(config.tokenizer)),config.max_length)classQwen3Provider(ModelProvider[DecoderForCausalLM[Qwen3DenseModel]]):def__init__(self,params:Qwen3DenseParameters,lora:LoRAConfig|None):self._params=paramsself._lora=LoRA(lora)ifloraisnotNoneelseNonedefinitialize_model_stage(self,context:InitializeModelStageContext)->InitializeModelStageResult:backbone=Qwen3DenseModel(params=self._params,stage=context.stage,hidden_states_snapshot_mode=HiddenStatesAggregationMode.no,enable_checkpointing=True,)model=DecoderForCausalLM(backbone,context.stage).bfloat16()# Maps the Hugging Face checkpoint keys to the d9d model keys.state_mapper=mapper_from_huggingface_qwen3_dense_for_causal_lm(self._params)ifself._loraisnotNone:# Wraps the matching layers in LoRA adapters and freezes everything else.# The LoRA mapper then moves the original weights of the wrapped layers to their new keys.lora_mapper=inject_peft_and_freeze(self._lora,model)state_mapper=ModelStateMapperSequential([state_mapper,lora_mapper])returnInitializeModelStageResult(model=model,state_mapper=state_mapper)defparallelize_model_stage(self,context:ParallelizeModelStageContext):parallelize_qwen3_dense_model(context.dist_context,context.model.model,context.stage)ifcontext.stage.is_current_stage_last:parallelize_causal_lm_head(context.model.head,context.dist_context)defprepare_export_model_stage(self,context:PrepareExportModelStageContext)->PrepareExportModelStageResult:ifself._loraisnotNone:# Merges the adapters into the base weights, so the export has the original architecture.merge_peft(self._lora,context.model)# Exports the model in the Hugging Face format.returnPrepareExportModelStageResult(state_mapper=mapper_to_huggingface_qwen3_dense_for_causal_lm(self._params))@dataclasses.dataclassclassCausalLMState:num_tokens:torch.TensorclassCausalLMTask(TrainTask[TextBatch,SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],SequenceCausalLMOutput,CausalLMState,]):defbuild_forward_inputs(self,ctx:BuildForwardInputsContext[TextBatch])->BuildForwardInputsResult[SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],CausalLMState]:labels=ctx.batch.labelsreturnBuildForwardInputsResult(input=SequenceInput(input_ids=ctx.batch.input_ids),shared=SequenceHeadShared(sequence=SequenceShared(position_ids=ctx.batch.position_ids),head=SequenceCausalLMHeadShared(labels=labels),),state=CausalLMState(num_tokens=(labels!=LM_IGNORE_INDEX).sum()),)defcompute_loss(self,ctx:ComputeLossContext[SequenceCausalLMOutput,CausalLMState])->ComputeLossResult:# The head returns the per-token cross-entropy loss. Padding positions hold zeros.num_tokens=ctx.state.num_tokensloss=ctx.pipeline_results.cross_entropy.sum()/num_tokens# Weighting each microbatch by its token count gives the mean loss per token across microbatches and ranks.returnComputeLossResult(loss=loss,loss_weight=num_tokens.float())defmain():config=ProjectConfig.model_validate(yaml.safe_load(Path(sys.argv[1]).read_text(encoding="utf-8")))trainer=TrainingConfigurator(mesh=config.mesh,parameters=config.trainer,task_provider=lambdactx:CausalLMTask(),model_provider=Qwen3Provider(config.model,config.lora),data_provider=AutoDataProvider(dataset_factory=lambdadist_context:build_dataset(config.data,dist_context),collator=TextDataset.collate,config=config.auto_data,),optimizer_provider=AutoOptimizerProvider(config.optimizer),lr_scheduler_provider=AutoLRSchedulerProvider(config.lr_scheduler),).configure()trainer.train()trainer.export(config.export_to,load_checkpoint=False)if__name__=="__main__":main()
importdataclassesimportmathimportsysfrompathlibimportPathimporttorchimporttorch.distributedasdistimportyamlfromd9d.core.dist_contextimportFLAT_DOMAIN,DeviceMeshParameters,DistributedContextfromd9d.loop.autoimportAutoDataConfig,AutoDataProviderfromd9d.loop.configimportInferenceConfigfromd9d.loop.controlimport(BuildForwardInputsContext,BuildForwardInputsResult,FinalizeContext,InferenceTask,ProcessOutputsContext,)fromd9d.loop.runimportInferenceConfiguratorfromd9d.module.block.headimportLM_IGNORE_INDEX,SequenceCausalLMHeadShared,SequenceCausalLMOutputfromd9d.module.model.ioimportSequenceHeadShared,SequenceInput,SequenceSharedfromd9d.module.model.qwen3_denseimportQwen3DenseParametersfromfinetuneimportDataConfig,Qwen3Provider,TextBatch,TextDataset,build_datasetfrompydanticimportBaseModel,ConfigDictclassEvaluationConfig(BaseModel):model_config=ConfigDict(extra="forbid")mesh:DeviceMeshParametersmodel:Qwen3DenseParametersdata:DataConfigauto_data:AutoDataConfiginference:InferenceConfig@dataclasses.dataclassclassPerplexityState:num_tokens:torch.TensorclassPerplexityTask(InferenceTask[TextBatch,SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],SequenceCausalLMOutput,PerplexityState,]):def__init__(self,dist_context:DistributedContext):self._dist_context=dist_contextself._loss_sum=torch.zeros((),dtype=torch.float64,device="cuda")self._num_tokens=torch.zeros((),dtype=torch.float64,device="cuda")defbuild_forward_inputs(self,ctx:BuildForwardInputsContext[TextBatch])->BuildForwardInputsResult[SequenceInput,SequenceHeadShared[SequenceCausalLMHeadShared],PerplexityState]:labels=ctx.batch.labelsreturnBuildForwardInputsResult(input=SequenceInput(input_ids=ctx.batch.input_ids),shared=SequenceHeadShared(sequence=SequenceShared(position_ids=ctx.batch.position_ids),head=SequenceCausalLMHeadShared(labels=labels),),state=PerplexityState(num_tokens=(labels!=LM_IGNORE_INDEX).sum()),)defprocess_outputs(self,ctx:ProcessOutputsContext[SequenceCausalLMOutput,PerplexityState]):# The head returns the per-token cross-entropy loss. Padding positions hold zeros.self._loss_sum+=ctx.pipeline_results.cross_entropy.sum()self._num_tokens+=ctx.state.num_tokensdeffinalize(self,ctx:FinalizeContext)->None:# Ranks that host no last pipeline stage add zeros, so a sum over all ranks covers every batch once.ifself._dist_context.mesh_params.is_distributed:group=self._dist_context.mesh_for(FLAT_DOMAIN).get_group()dist.all_reduce(self._loss_sum,group=group)dist.all_reduce(self._num_tokens,group=group)mean_loss=(self._loss_sum/self._num_tokens).item()ifself._dist_context.is_main_process:self._dist_context.logger.info(f"Mean loss {mean_loss:.4f}, perplexity {math.exp(mean_loss):.2f}")defmain():config=EvaluationConfig.model_validate(yaml.safe_load(Path(sys.argv[1]).read_text(encoding="utf-8")))# The checkpoint to evaluate, e.g. the original model or the export of a fine-tuning run.config.inference.model_stage_factory.source_checkpoint=Path(sys.argv[2])inference=InferenceConfigurator(mesh=config.mesh,parameters=config.inference,task_provider=lambdactx:PerplexityTask(ctx.dist_context),model_provider=Qwen3Provider(config.model,lora=None),data_provider=AutoDataProvider(dataset_factory=lambdadist_context:build_dataset(config.data,dist_context),collator=TextDataset.collate,config=config.auto_data,),).configure()inference.infer()if__name__=="__main__":main()