Text Generation
Transformers
Safetensors
Arabic
llama
arabic
reasoning
chain-of-thought
math
gsm8k
small-language-model
slm
sft
conversational
text-generation-inference
File size: 9,952 Bytes
867d0f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""
Generate the synthetic Arabic math-reasoning corpus with google/gemma-4-12B-it.

Resumable by construction:

  * A "task" is one generation call that asks for ITEMS_PER_TASK problems. Task N's prompt is a
    pure function of N (synth_common.build_task), so nothing about the plan is stored — restart
    the script and it redraws the identical prompts.
  * Every completed task is appended to out_synth/generations.jsonl as one line and fsynced.
    On start the file is replayed, completed task_ids are skipped, and a torn final line (a
    machine that died mid-write) is dropped. Worst case a crash costs one in-flight batch.
  * The stop condition is *accepted rows*, not tasks: each cached generation is re-validated on
    load, so a resumed run knows how many good rows it already has and issues only what's missing.

Backends:
  BACKEND=hf     transformers batched generation                       (default, works here)
  BACKEND=vllm   vLLM continuous batching                             (see the note below)

vLLM cannot serve Gemma 4 on this box: every vLLM release that knows the gemma4 architecture
(>= 0.20.2) pins torch 2.11, which is a CUDA 13 build, and this host's driver (550 / CUDA 12.4)
caps at CUDA 12.x — vllm imports die on `libcudart.so.13`. The vLLM path is kept working for a
box with driver >= 580, or for a gemma3/Qwen generator on the pinned .venv-vllm stack.

Usage:
  P=/notebooks/50M/.venv-lfm2/bin/python
  TARGET=100000 $P -u synth_generate.py
"""
import json
import os
import sys
import time
from pathlib import Path

import synth_common as sc

MODEL_DIR   = os.environ.get("GEN_MODEL", "./models/gemma-4-12B-it")
OUT_DIR     = Path(os.environ.get("OUT_DIR", "out_synth"))
CACHE       = OUT_DIR / "generations.jsonl"
TARGET      = int(os.environ.get("TARGET", 100_000))
BATCH       = int(os.environ.get("BATCH", 32))
MAX_NEW     = int(os.environ.get("MAX_NEW", 1400))
TEMPERATURE = float(os.environ.get("TEMPERATURE", 0.9))
TOP_P       = float(os.environ.get("TOP_P", 0.95))
SEED        = int(os.environ.get("SEED", 1234))
BACKEND     = os.environ.get("BACKEND", "hf")
# Recorded on every cached row so a merged multi-node corpus stays attributable.
MODEL_NAME  = os.environ.get("GEN_MODEL_NAME", os.path.basename(MODEL_DIR.rstrip("/")))
# Multimodal wrappers can default to eager attention, which is several times slower to decode.
ATTN        = os.environ.get("ATTN", "sdpa")
MAX_TASKS   = int(os.environ.get("MAX_TASKS", 400_000))
# Two machines generating at once must own disjoint task-id ranges: build_task() is a pure
# function of the id, so overlapping ranges redraw byte-identical prompts and every row the
# second machine produces dies as a duplicate template in build_synth_dataset.py. START_TASK
# offsets this node's range; MAX_TASKS is counted from there, not from zero.
START_TASK  = int(os.environ.get("START_TASK", 0))
# Which slice of the variation grid to draw from. "default" is the original 15-operation grid;
# "relational" is the comparison pool (x = 2y, x = y/2, x = y + n ...) that the default grid never
# produced. Use a disjoint START_TASK for a relational run — same rule as two nodes not colliding.
POOL        = os.environ.get("POOL", "default")
# Qwen3-style templates open a <think> block in the generation prompt unless this is passed,
# and the model then spends the whole MAX_NEW budget reasoning before it ever emits a tag.
CHAT_KWARGS = {"enable_thinking": False} if os.environ.get("NO_THINK", "0") == "1" else {}


def load_cache():
    """-> (set of finished task_ids, accepted row count). Tolerates a truncated final line."""
    done, accepted = set(), 0
    if not CACHE.exists():
        return done, accepted
    with open(CACHE, encoding="utf-8") as fh:
        for line in fh:
            try:
                rec = json.loads(line)
            except json.JSONDecodeError:
                print("[!] dropping truncated final line of the cache")
                continue
            done.add(rec["task_id"])
            for item in sc.parse_items(rec["raw"]):
                ok, _ = sc.validate(item)
                accepted += ok
    return done, accepted


class HFBackend:
    """Batched transformers generation. No paged attention, but B sequences decode in parallel."""

    def __init__(self):
        import torch
        import transformers
        from transformers import AutoProcessor, AutoTokenizer

        self.torch = torch
        try:
            self.tok = AutoProcessor.from_pretrained(MODEL_DIR).tokenizer
        except Exception:
            self.tok = AutoTokenizer.from_pretrained(MODEL_DIR)
        self.tok.padding_side = "left"
        if self.tok.pad_token_id is None:
            self.tok.pad_token = self.tok.eos_token
        print(f"[*] loading {MODEL_DIR} (bf16)", flush=True)
        # gemma-4-*-it is a unified multimodal checkpoint, so the plain causal-LM auto class does
        # not always claim it. Try the multimodal auto classes first and fall back.
        self.model, last = None, None
        for name in ("AutoModelForMultimodalLM", "AutoModelForImageTextToText",
                     "AutoModelForCausalLM"):
            cls = getattr(transformers, name, None)
            if cls is None:
                continue
            try:
                self.model = cls.from_pretrained(
                    MODEL_DIR, dtype=torch.bfloat16, device_map="cuda:0",
                    attn_implementation=ATTN).eval()
                print(f"    loaded via {name}", flush=True)
                break
            except Exception as exc:                       # noqa: BLE001 - report the last failure
                last = f"{name}: {exc}"
        if self.model is None:
            raise RuntimeError(f"could not load {MODEL_DIR}; last error -> {last}")
        self.model.config.use_cache = True

    def generate(self, prompts):
        texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
                                              tokenize=False, add_generation_prompt=True,
                                              **CHAT_KWARGS)
                 for p in prompts]
        enc = self.tok(texts, return_tensors="pt", padding=True, add_special_tokens=False).to("cuda:0")
        with self.torch.no_grad():
            out = self.model.generate(**enc, max_new_tokens=MAX_NEW, do_sample=True,
                                      temperature=TEMPERATURE, top_p=TOP_P,
                                      pad_token_id=self.tok.pad_token_id)
        width = enc["input_ids"].shape[1]
        return [self.tok.decode(seq[width:], skip_special_tokens=True) for seq in out]


class VLLMBackend:
    def __init__(self):
        from vllm import LLM, SamplingParams

        kw = {}
        if os.environ.get("QUANT"):                 # e.g. QUANT=modelopt for NVFP4 checkpoints
            kw["quantization"] = os.environ["QUANT"]
        self.llm = LLM(model=MODEL_DIR, dtype=os.environ.get("DTYPE", "bfloat16"),
                       max_num_seqs=BATCH,
                       gpu_memory_utilization=float(os.environ.get("GPU_UTIL", 0.90)),
                       max_model_len=int(os.environ.get("MAX_LEN", 4096)), **kw)
        self.params = SamplingParams(temperature=TEMPERATURE, top_p=TOP_P, max_tokens=MAX_NEW,
                                     seed=None)
        self.tok = self.llm.get_tokenizer()

    def generate(self, prompts):
        texts = [self.tok.apply_chat_template([{"role": "user", "content": p}],
                                              tokenize=False, add_generation_prompt=True,
                                              **CHAT_KWARGS)
                 for p in prompts]
        outs = self.llm.generate(texts, self.params)
        return [o.outputs[0].text for o in outs]


def main():
    OUT_DIR.mkdir(exist_ok=True)
    done, accepted = load_cache()
    print(f"[*] cache: {len(done):,} tasks done, {accepted:,} rows accepted "
          f"(target {TARGET:,}) | model {MODEL_NAME} | tasks {START_TASK:,}.."
          f"{START_TASK + MAX_TASKS:,}", flush=True)
    if accepted >= TARGET:
        print("[+] target already met — nothing to do")
        return

    backend = (VLLMBackend if BACKEND == "vllm" else HFBackend)()

    next_id = START_TASK
    last_id = START_TASK + MAX_TASKS
    started, gen_rows, gen_tasks = time.time(), 0, 0
    fh = open(CACHE, "a", encoding="utf-8")
    while accepted < TARGET and next_id < last_id:
        batch = []
        while len(batch) < BATCH and next_id < last_id:
            if next_id not in done:
                batch.append((next_id, *sc.build_task(next_id, SEED, POOL)))
            next_id += 1
        if not batch:
            break

        t0 = time.time()
        raws = backend.generate([p for _, _, p in batch])
        batch_accept = 0
        for (task_id, axes, _), raw in zip(batch, raws):
            fh.write(json.dumps({"task_id": task_id, "axes": axes, "raw": raw,
                                 "model": MODEL_NAME}, ensure_ascii=False) + "\n")
            for item in sc.parse_items(raw):
                ok, _ = sc.validate(item)
                batch_accept += ok
        fh.flush()
        os.fsync(fh.fileno())          # a crash costs the in-flight batch, never the cache

        accepted += batch_accept
        gen_rows += batch_accept
        gen_tasks += len(batch)
        dt = time.time() - t0
        rate = gen_rows / max(time.time() - started, 1e-9)
        eta = (TARGET - accepted) / rate / 3600 if rate > 0 else float("inf")
        print(f"[{accepted:>7,}/{TARGET:,}] +{batch_accept:>3} rows  "
              f"batch {len(batch)} in {dt:5.1f}s  "
              f"accept {batch_accept / (len(batch) * sc.ITEMS_PER_TASK):5.1%}  "
              f"{rate * 3600:,.0f} rows/h  ETA {eta:4.1f}h", flush=True)

    fh.close()
    print(f"[+] {accepted:,} accepted rows in cache after {gen_tasks:,} new tasks")


if __name__ == "__main__":
    sys.exit(main())