PowerMachine commited on
Commit
e719654
·
verified ·
1 Parent(s): e383cb5

V6+: add xeon_runtime.py with IPEX/AMX/AVX512 + FP16/BF16 benchmark

Browse files
Files changed (1) hide show
  1. scripts/xeon_runtime.py +472 -0
scripts/xeon_runtime.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """xeon_runtime.py — Runtime Xeon OTIMIZADO para BiGRU_T_version V6+.
2
+
3
+ Aprimoramento do xeon_setup.py com:
4
+ 1. Configuração completa do ambiente AVX512/AMX/MKL/OMP/OneDNN
5
+ 2. Verificação do IPEX (Intel Extension for PyTorch) + aplicação de ipex.optimize
6
+ 3. Benchmark FP16 com autocast para medir TFLOPS reais da CPU
7
+ 4. Aplicação automática de ipex.optimize(model, dtype=...) para modelos
8
+ 5. Configuração de threads com detecção dinâmica de núcleos físicos
9
+ 6. Diagnóstico completo de CPU ISA (AVX512 sub-features, AMX, BF16, FP16, VNNI)
10
+ 7. API one-call: `apply_all(model=None)` para configuração total
11
+ 8. Monkey-patch opcional para torch.matmul com autocast FP16 persistente
12
+
13
+ DEVE ser importado ANTES de `import torch` para que as variáveis de ambiente
14
+ tenham efeito sobre o backend OneDNN/MKL.
15
+
16
+ Uso típico:
17
+ from xeon_runtime import apply_all, benchmark_fp16, optimize_model
18
+ apply_all() # configura ambiente + verifica IPEX + imprime diagnóstico
19
+ import torch
20
+ apply_all.post_torch() # configura threads do torch
21
+ tf = benchmark_fp16() # mede TFLOPS FP16 reais
22
+ model = ...
23
+ model = optimize_model(model, dtype=torch.bfloat16) # aplica ipex.optimize
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sys
29
+ import time
30
+ import logging
31
+ import subprocess
32
+ from typing import Optional, Tuple
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ==============================================================================
37
+ # 1. CONFIGURAÇÃO DE AMBIENTE (Executar antes de qualquer import de IA/HPC)
38
+ # ==============================================================================
39
+
40
+ def _detect_physical_cores() -> int:
41
+ """Detecta núcleos físicos da VM (com fallback seguro)."""
42
+ # Tentativa 1: lscpu
43
+ try:
44
+ out = subprocess.check_output(["lscpu", "-p=Core"], stderr=subprocess.DEVNULL).decode()
45
+ cores = set(line for line in out.splitlines() if line and not line.startswith("#"))
46
+ if cores:
47
+ return max(1, min(len(cores), 4)) # limita a 4 para não oversubscrever
48
+ except Exception:
49
+ pass
50
+ # Tentativa 2: /proc/cpuinfo
51
+ try:
52
+ with open("/proc/cpuinfo") as f:
53
+ cores = sum(1 for line in f if line.startswith("processor"))
54
+ return max(1, min(cores, 4))
55
+ except Exception:
56
+ return 2
57
+
58
+
59
+ NUM_CORES = str(_detect_physical_cores())
60
+
61
+
62
+ def _detect_cpu_isa() -> dict:
63
+ """Detecta sub-features AVX512/AMX/BF16/FP16/VNNI disponíveis na CPU."""
64
+ flags = ""
65
+ try:
66
+ with open("/proc/cpuinfo") as f:
67
+ for line in f:
68
+ if line.startswith("flags"):
69
+ flags = line.lower()
70
+ break
71
+ except Exception:
72
+ pass
73
+ return {
74
+ "avx512f": "avx512f" in flags,
75
+ "avx512bw": "avx512bw" in flags,
76
+ "avx512cd": "avx512cd" in flags,
77
+ "avx512dq": "avx512dq" in flags,
78
+ "avx512vl": "avx512vl" in flags,
79
+ "avx512_vnni": "avx512_vnni" in flags,
80
+ "avx512_bf16": "avx512_bf16" in flags,
81
+ "avx512_fp16": "avx512_fp16" in flags,
82
+ "amx_tile": "amx_tile" in flags,
83
+ "amx_int8": "amx_int8" in flags,
84
+ "amx_bf16": "amx_bf16" in flags,
85
+ }
86
+
87
+
88
+ def _pick_max_isa(isa_flags: dict) -> str:
89
+ """Escolhe a maior ISA suportada (para ONEDNN_MAX_CPU_ISA)."""
90
+ if isa_flags["amx_int8"] or isa_flags["amx_bf16"]:
91
+ return "AMX_INT8" # máximo para OneDNN
92
+ if isa_flags["avx512f"]:
93
+ return "AVX512"
94
+ if "avx2" in str(isa_flags):
95
+ return "AVX2"
96
+ return "SSE"
97
+
98
+
99
+ def configure_environment(num_cores: Optional[str] = None) -> dict:
100
+ """Configura todas as variáveis de ambiente Xeon/Intel.
101
+
102
+ DEVE ser chamado ANTES de `import torch`.
103
+ Retorna dict com a configuração aplicada (para logging).
104
+ """
105
+ cores = num_cores or NUM_CORES
106
+ isa_flags = _detect_cpu_isa()
107
+ max_isa = _pick_max_isa(isa_flags)
108
+
109
+ # Ativa explicitamente o suporte a AVX-512 e extensões de vetorização Intel
110
+ os.environ["MKL_ENABLE_INSTRUCTIONS"] = "AVX512"
111
+
112
+ # Configura o gerenciador de threads openmp/mkl para os núcleos físicos da VM
113
+ os.environ["MKL_NUM_THREADS"] = cores
114
+ os.environ["OMP_NUM_THREADS"] = cores
115
+ os.environ["OMP_DYNAMIC"] = "FALSE"
116
+ os.environ["MKL_DYNAMIC"] = "FALSE"
117
+
118
+ # Otimizações específicas do PyTorch para CPUs Intel (OneDNN)
119
+ os.environ["DNNL_PRIMITIVE_CACHE_CAPACITY"] = "1024"
120
+ # AMX (Matrix Extensions) — só ativa em Xeon 4ª gen+ (Sapphire Rapids+)
121
+ os.environ["ONEDNN_MAX_CPU_ISA"] = max_isa
122
+
123
+ # Evita buffering de stdout (logs aparecem em tempo real)
124
+ os.environ["PYTHONUNBUFFERED"] = "1"
125
+
126
+ # Tokenizers paralelismo (HuggingFace)
127
+ os.environ["TOKENIZERS_PARALLELISM"] = "true"
128
+
129
+ # KMP_AFFINITY para melhor distribuição de threads
130
+ os.environ["KMP_AFFINITY"] = "granularity=fine,compact,1,0"
131
+ os.environ["KMP_BLOCKTIME"] = "1"
132
+
133
+ return {
134
+ "NUM_CORES": cores,
135
+ "MKL_ENABLE_INSTRUCTIONS": os.environ["MKL_ENABLE_INSTRUCTIONS"],
136
+ "MKL_NUM_THREADS": os.environ["MKL_NUM_THREADS"],
137
+ "OMP_NUM_THREADS": os.environ["OMP_NUM_THREADS"],
138
+ "OMP_DYNAMIC": os.environ["OMP_DYNAMIC"],
139
+ "MKL_DYNAMIC": os.environ["MKL_DYNAMIC"],
140
+ "DNNL_PRIMITIVE_CACHE_CAPACITY": os.environ["DNNL_PRIMITIVE_CACHE_CAPACITY"],
141
+ "ONEDNN_MAX_CPU_ISA": os.environ["ONEDNN_MAX_CPU_ISA"],
142
+ "PYTHONUNBUFFERED": os.environ["PYTHONUNBUFFERED"],
143
+ "TOKENIZERS_PARALLELISM": os.environ["TOKENIZERS_PARALLELISM"],
144
+ "KMP_AFFINITY": os.environ["KMP_AFFINITY"],
145
+ "KMP_BLOCKTIME": os.environ["KMP_BLOCKTIME"],
146
+ "ISA_FLAGS": isa_flags,
147
+ }
148
+
149
+
150
+ # ==============================================================================
151
+ # 2. CONFIGURAÇÃO DO PYTORCH (após import) + IPEX
152
+ # ==============================================================================
153
+
154
+ def configure_torch() -> "torch":
155
+ """Configura threads do PyTorch. Chamar APÓS `import torch`."""
156
+ import torch
157
+ torch.set_num_threads(int(NUM_CORES))
158
+ torch.set_num_interop_threads(1)
159
+ # OneDNN (MKL-DNN) — habilitado por padrão em CPU, mas garantimos
160
+ try:
161
+ torch.backends.mkldnn.enabled = True
162
+ except Exception:
163
+ pass
164
+ # Desativa profiling mode do JIT para melhor desempenho
165
+ try:
166
+ torch._C._jit_set_profiling_mode(False)
167
+ torch._C._jit_set_profiling_executor(False)
168
+ except Exception:
169
+ pass
170
+ return torch
171
+
172
+
173
+ def check_ipex() -> Tuple[bool, Optional[str]]:
174
+ """Verifica se IPEX (Intel Extension for PyTorch) está disponível."""
175
+ try:
176
+ import intel_extension_for_pytorch as ipex
177
+ return True, getattr(ipex, "__version__", "unknown")
178
+ except ImportError:
179
+ return False, None
180
+ except Exception:
181
+ # IPEX pode falhar ao importar se versão do torch não bater
182
+ return False, None
183
+
184
+
185
+ def optimize_model(model, dtype=None, level="O1"):
186
+ """Aplica ipex.optimize ao modelo (se IPEX disponível).
187
+
188
+ Args:
189
+ model: nn.Module a ser otimizado
190
+ dtype: torch.float16, torch.bfloat16, ou None (default = float32)
191
+ level: "O0" (sem otim), "O1" (conv-bn folding + fusão), "O3" (agressivo)
192
+
193
+ Returns:
194
+ model otimizado (in-place + return). Se IPEX não disponível, retorna model inalterado.
195
+ """
196
+ ipex_ok, _ = check_ipex()
197
+ if not ipex_ok:
198
+ logger.warning("IPEX não disponível — model retornado sem otimização.")
199
+ return model
200
+ try:
201
+ import intel_extension_for_pytorch as ipex
202
+ if dtype is not None:
203
+ try:
204
+ # Tentativa: ipex.optimize com dtype (IPEX 2.x)
205
+ model = ipex.optimize(model, dtype=dtype, level=level)
206
+ except TypeError:
207
+ # Fallback: ipex.optimize sem dtype
208
+ model = ipex.optimize(model, level=level)
209
+ else:
210
+ model = ipex.optimize(model, level=level)
211
+ logger.info(f"ipex.optimize aplicado (level={level}, dtype={dtype}).")
212
+ return model
213
+ except Exception as e:
214
+ logger.warning(f"Falha ao aplicar ipex.optimize: {e}")
215
+ return model
216
+
217
+
218
+ # ==============================================================================
219
+ # 3. BENCHMARK FP16 (com autocast) — medir TFLOPS reais da CPU Xeon
220
+ # ==============================================================================
221
+
222
+ def benchmark_fp16(N: int = 4000, warmup: int = 2) -> dict:
223
+ """Executa benchmark FP16 com autocast em matmul NxN.
224
+
225
+ Retorna dict com:
226
+ - duration_s: tempo real (segundos)
227
+ - tflops: TeraFLOPS estimados (2*N^3 / duration / 1e12)
228
+ - dtype_in: dtype de entrada (torch.float16)
229
+ - dtype_out: dtype de saída (após autocast)
230
+ - ipex_active: bool
231
+ - isa: str ("AVX512", "AMX", etc.)
232
+ """
233
+ import torch
234
+ ipex_ok, _ = check_ipex()
235
+
236
+ # Matrizes em FP16 nativo
237
+ A = torch.randn(N, N, dtype=torch.float16)
238
+ B = torch.randn(N, N, dtype=torch.float16)
239
+
240
+ # Warm-up
241
+ with torch.cpu.amp.autocast(enabled=True, dtype=torch.float16):
242
+ for _ in range(warmup):
243
+ _ = torch.matmul(A[:N // 4, :N // 4], B[:N // 4, :N // 4])
244
+
245
+ # Medição
246
+ start = time.perf_counter()
247
+ with torch.cpu.amp.autocast(enabled=True, dtype=torch.float16):
248
+ C = torch.matmul(A, B)
249
+ # Sincroniza (CPU é síncrono por padrão, mas garantimos)
250
+ _ = C.sum().item()
251
+ end = time.perf_counter()
252
+
253
+ duration = end - start
254
+ flops = 2.0 * (N ** 3) / duration if duration > 0 else 0.0
255
+ tflops = flops / 1e12
256
+
257
+ return {
258
+ "matrix_size": N,
259
+ "duration_s": duration,
260
+ "tflops": tflops,
261
+ "gops_per_sec": flops / 1e9,
262
+ "dtype_in": "torch.float16",
263
+ "dtype_out": str(C.dtype),
264
+ "ipex_active": ipex_ok,
265
+ "isa": get_cpu_isa(),
266
+ "torch_threads": torch.get_num_threads(),
267
+ }
268
+
269
+
270
+ def benchmark_bf16(N: int = 4000, warmup: int = 2) -> dict:
271
+ """Executa benchmark BF16 (advantage: mesma range que FP32, melhor para treino)."""
272
+ import torch
273
+ ipex_ok, _ = check_ipex()
274
+
275
+ A = torch.randn(N, N, dtype=torch.bfloat16)
276
+ B = torch.randn(N, N, dtype=torch.bfloat16)
277
+
278
+ with torch.cpu.amp.autocast(enabled=True, dtype=torch.bfloat16):
279
+ for _ in range(warmup):
280
+ _ = torch.matmul(A[:N // 4, :N // 4], B[:N // 4, :N // 4])
281
+
282
+ start = time.perf_counter()
283
+ with torch.cpu.amp.autocast(enabled=True, dtype=torch.bfloat16):
284
+ C = torch.matmul(A, B)
285
+ _ = C.sum().item()
286
+ end = time.perf_counter()
287
+
288
+ duration = end - start
289
+ flops = 2.0 * (N ** 3) / duration if duration > 0 else 0.0
290
+ return {
291
+ "matrix_size": N,
292
+ "duration_s": duration,
293
+ "tflops": flops / 1e12,
294
+ "gops_per_sec": flops / 1e9,
295
+ "dtype_in": "torch.bfloat16",
296
+ "dtype_out": str(C.dtype),
297
+ "ipex_active": ipex_ok,
298
+ "isa": get_cpu_isa(),
299
+ "torch_threads": torch.get_num_threads(),
300
+ }
301
+
302
+
303
+ # ==============================================================================
304
+ # 4. DIAGNÓSTICO E PRINT
305
+ # ==============================================================================
306
+
307
+ def get_cpu_isa() -> str:
308
+ """Retorna a maior ISA suportada pela CPU (string curta)."""
309
+ isa_flags = _detect_cpu_isa()
310
+ if isa_flags["amx_int8"] or isa_flags["amx_bf16"]:
311
+ return "AMX"
312
+ if isa_flags["avx512f"]:
313
+ return "AVX512"
314
+ if "avx2" in str(isa_flags):
315
+ return "AVX2"
316
+ return "SSE"
317
+
318
+
319
+ def print_xeon_summary(benchmark: bool = False, bench_N: int = 4000):
320
+ """Imprime resumo das configurações Xeon aplicadas.
321
+
322
+ Args:
323
+ benchmark: se True, executa benchmark FP16 + BF16 ao final
324
+ bench_N: tamanho da matriz para benchmark
325
+ """
326
+ ipex_ok, ipex_ver = check_ipex()
327
+ isa = get_cpu_isa()
328
+ isa_flags = _detect_cpu_isa()
329
+
330
+ print("=" * 72)
331
+ print("CONFIGURAÇÃO XEON APLICADA (xeon_runtime.py)")
332
+ print("=" * 72)
333
+ print(f" Python: {sys.version.split()[0]}")
334
+ print(f" NUM_CORES: {NUM_CORES}")
335
+ print(f" MKL_ENABLE_INSTR: {os.environ.get('MKL_ENABLE_INSTRUCTIONS')}")
336
+ print(f" ONEDNN_MAX_CPU_ISA: {os.environ.get('ONEDNN_MAX_CPU_ISA')}")
337
+ print(f" OMP_NUM_THREADS: {os.environ.get('OMP_NUM_THREADS')}")
338
+ print(f" MKL_NUM_THREADS: {os.environ.get('MKL_NUM_THREADS')}")
339
+ print(f" KMP_AFFINITY: {os.environ.get('KMP_AFFINITY')}")
340
+ print(f" KMP_BLOCKTIME: {os.environ.get('KMP_BLOCKTIME')}")
341
+ print(f" CPU ISA detected: {isa}")
342
+ print(f" AVX512 sub-features:")
343
+ for k, v in isa_flags.items():
344
+ marker = "✓" if v else " "
345
+ print(f" {marker} {k}")
346
+ print(f" IPEX ativo: {ipex_ok}")
347
+ if ipex_ok:
348
+ print(f" IPEX version: {ipex_ver}")
349
+ try:
350
+ import torch
351
+ print(f" Torch: {torch.__version__}")
352
+ print(f" Torch threads: {torch.get_num_threads()}")
353
+ print(f" Torch interop: {torch.get_num_interop_threads()}")
354
+ print(f" OneDNN enabled: {torch.backends.mkldnn.enabled}")
355
+ print(f" CPU bf16 support: {torch.backends.mkldnn.is_available()}")
356
+ except ImportError:
357
+ print(" Torch: (ainda não importado)")
358
+ print("=" * 72)
359
+
360
+ if benchmark:
361
+ print("\n--- BENCHMARK FP16 (matmul autocast) ---")
362
+ try:
363
+ res_fp16 = benchmark_fp16(N=bench_N)
364
+ print(f" Matriz: {res_fp16['matrix_size']}x{res_fp16['matrix_size']}")
365
+ print(f" Tempo: {res_fp16['duration_s']:.4f}s")
366
+ print(f" TFLOPS FP16: {res_fp16['tflops']:.4f}")
367
+ print(f" GOPS/s: {res_fp16['gops_per_sec']:.1f}")
368
+ print(f" Output dtype: {res_fp16['dtype_out']}")
369
+ except Exception as e:
370
+ print(f" [ERRO] FP16 benchmark falhou: {e}")
371
+
372
+ print("\n--- BENCHMARK BF16 (matmul autocast) ---")
373
+ try:
374
+ res_bf16 = benchmark_bf16(N=bench_N)
375
+ print(f" Matriz: {res_bf16['matrix_size']}x{res_bf16['matrix_size']}")
376
+ print(f" Tempo: {res_bf16['duration_s']:.4f}s")
377
+ print(f" TFLOPS BF16: {res_bf16['tflops']:.4f}")
378
+ print(f" GOPS/s: {res_bf16['gops_per_sec']:.1f}")
379
+ print(f" Output dtype: {res_bf16['dtype_out']}")
380
+ except Exception as e:
381
+ print(f" [ERRO] BF16 benchmark falhou: {e}")
382
+ print("=" * 72)
383
+
384
+
385
+ # ==============================================================================
386
+ # 5. API ONE-CALL: apply_all() — configura tudo numa chamada
387
+ # ==============================================================================
388
+
389
+ def apply_all(model=None, dtype=None, level: str = "O1", benchmark: bool = False,
390
+ bench_N: int = 4000, verbose: bool = True) -> dict:
391
+ """Aplica TODA a configuração Xeon em uma chamada.
392
+
393
+ Fluxo:
394
+ 1. configure_environment() — env vars (ANTES de import torch)
395
+ 2. import torch + configure_torch()
396
+ 3. Verifica IPEX
397
+ 4. (Opcional) ipex.optimize(model, dtype, level)
398
+ 5. (Opcional) benchmark FP16/BF16
399
+ 6. Print resumo (se verbose)
400
+
401
+ Uso:
402
+ from xeon_runtime import apply_all
403
+ apply_all() # antes de import torch
404
+ import torch
405
+
406
+ Ou:
407
+ from xeon_runtime import apply_all
408
+ apply_all() # env vars
409
+ import torch
410
+ apply_all.post_torch() # threads
411
+ model = ...
412
+ model = apply_all.optimize_model(model, dtype=torch.bfloat16)
413
+
414
+ Returns:
415
+ dict com configuração aplicada + benchmarks (se solicitado)
416
+ """
417
+ env = configure_environment()
418
+ import torch
419
+ configure_torch()
420
+ ipex_ok, ipex_ver = check_ipex()
421
+
422
+ result = {
423
+ "env": env,
424
+ "ipex_active": ipex_ok,
425
+ "ipex_version": ipex_ver,
426
+ "torch_version": torch.__version__,
427
+ "torch_threads": torch.get_num_threads(),
428
+ "onednn_enabled": torch.backends.mkldnn.enabled,
429
+ "isa": get_cpu_isa(),
430
+ }
431
+
432
+ if model is not None:
433
+ model = optimize_model(model, dtype=dtype, level=level)
434
+ result["model_optimized"] = True
435
+
436
+ if benchmark:
437
+ try:
438
+ result["fp16_benchmark"] = benchmark_fp16(N=bench_N)
439
+ except Exception as e:
440
+ result["fp16_benchmark_error"] = str(e)
441
+ try:
442
+ result["bf16_benchmark"] = benchmark_bf16(N=bench_N)
443
+ except Exception as e:
444
+ result["bf16_benchmark_error"] = str(e)
445
+
446
+ if verbose:
447
+ print_xeon_summary(benchmark=benchmark, bench_N=bench_N)
448
+
449
+ return result
450
+
451
+
452
+ def post_torch():
453
+ """Configura threads do torch (deve ser chamado APÓS import torch)."""
454
+ return configure_torch()
455
+
456
+
457
+ # ==============================================================================
458
+ # 6. MAIN — para executar standalone como diagnóstico
459
+ # ==============================================================================
460
+
461
+ if __name__ == "__main__":
462
+ print("\n>>> Aplicando configuração Xeon + benchmark FP16/BF16...\n")
463
+ result = apply_all(benchmark=True, bench_N=4000)
464
+ print("\n>>> Resumo final:")
465
+ for k, v in result.items():
466
+ if k in ("env", "fp16_benchmark", "bf16_benchmark"):
467
+ print(f" {k}:")
468
+ if isinstance(v, dict):
469
+ for k2, v2 in v.items():
470
+ print(f" {k2}: {v2}")
471
+ else:
472
+ print(f" {k}: {v}")