Skip to content

Commit c6b26f0

Browse files
authored
Optimize convolution and groupnorm (#98)
* Add a channels-last-native Triton GroupNorm ScaFFold's GroupNorm was the only operator forcing a layout change: ATen's kernel launches one workgroup per (batch, group) row -- 8 of them at the benchmark's defaults -- so on a 228-CU MI300A it ran at a small fraction of achievable bandwidth, and it was the sole reason channels-last broke. This is an NDHWC-native kernel: Welford statistics, fp32/bf16/fp16 with torch's autocast contract, an int64 tile-base path for tensors past 2^31 elements, and a fused ReLU that is bit-exact against the unfused form. Registered as a custom op with fake kernels and register_autograd, so it traces under torch.compile(fullgraph=True) and composes with DistConv's DCTensor. Determinism is a contract here, not an accident: no float atomics, and the grid, split, tile and reduction order are pure functions of the shape. Verified run-to-run and across interpreters. Nothing imports this yet -- the wiring is the next commit. * Route the UNet GroupNorm through the Triton kernel FastGroupNorm becomes a three-rung ladder -- the native Triton kernel, a torch.compile'd functional, and stock eager -- with the routing decision made per call and the rejections tested in order: an explicit opt-out, a rung that has already failed in this process, an active torch.func transform, a non-CUDA tensor, a tensor subclass other than DistConv's DCTensor, a GPU the launch tables were not tuned on, and anything the kernel's own is_supported rejects. The ladder primitives live in _rungs.py, which the convolution ladder will share. The hardware guard is a preference rather than a correctness condition -- the kernel is correct anywhere Triton lowers it, and what is unknown elsewhere is only its speed -- so an explicit opt-in overrides it and nothing else on that list can be overridden at all. DoubleConv now asks the GroupNorm for its ReLU: the Triton kernel folds the activation into its forward store, removing a streaming pass worth 38% of the forward at the shapes that dominate the step. The nn.ReLU slots stay occupied by nn.Identity so nn.Sequential does not renumber its children and existing checkpoints keep loading. * Add a Triton 3-D convolution package A self-contained NDHWC implicit-GEMM convolution for MI300A / gfx942, built to replace MIOpen in this benchmark and to be upstreamable to DistConv on its own terms. It imports nothing from ScaFFold. Four kernels serve seven operator-directions. Backward-data is not a kernel at all -- it is the forward contraction on a permuted weight -- and the transposed operator's backward directions are its own forward kernel and backward-weight with the operands swapped. That reuse is the main structural result. The corpus is recorded from real ScaFFold calls, and it records the *shape form*: the logical convolution, the halo'd unpadded one DistConv hands a backend, and the padded one this package's adapter actually issues. Those are different problems, and conflating them has been the most expensive mistake in this work, so ConvProblem names which is which and the benchmark driver takes --form. Backward-weight is deterministic by default: split-K with a reduction tree whose split count, tile and order are pure functions of the shape. Correctness is checked against fp64 references under a three-tier tolerance policy, with a bitwise corpus for the exactly-representable cases. Also here: the benchmark harness, which times kernels through CUDA-graph replay with 95% intervals and an online iteration count, and which can run without the MIOpen control -- 98% of a two-arm capture's wall clock was MIOpen's find, not measurement. * Route every convolution through the Triton rung FastConv3d and FastConvTranspose3d mirror FastGroupNorm: drop-in nn.Conv3d and nn.ConvTranspose3d, same parameters under the same names, no buffers, a rung ladder sharing _rungs.py, and MIOpen underneath everything the kernel declines. The transposed operator gets a factory of its own rather than a flag, because it is a different operator -- the weight's channel axes are the other way round and a different set of kernels sits behind it. The adapter performs the halo exchange itself, above autograd, rather than leaving it to the one DistConv does below. That has a consequence for the shape the kernel sees, and it is the thing to know when reading any number from this work: only the split axis is halo'd, so padding=1 survives on the other two and every k=3 convolution in the network is padded at every configuration -- unsharded there is nothing to halo at all. The benchmark corpus calls that the adapter form, and it is what production issues. * Cite findings rather than the scratch tree that produced them Every comment and docstring in the committed tree stood on its own path into the untracked work/ scratch directory: 70 references across 20 files, all of them dangling for anyone who clones this branch. The measurement each one supported is kept and stated as a result -- the number, the direction of the effect, and the "this was tried and it loses" warnings that stop a closed question being re-litigated -- while the path, the capture filename, the section number of an unshipped document and the blow-by-blow methodology go. Three pointers were dead even with work/ present and are simply gone: the transposed benchmark driver the conv_bench docstring narrated a refactor away from, a tuned table's named source capture, and the review commit SHA in the GroupNorm wiring tests. The corpus JSON keeps model-analysis/unet_shapes.py as its provenance; nothing parses either file's "source" field. * Add helper script to run tests. * Satisfy the style checks CI actually runs The style workflow runs `ruff format --diff .` and `ruff check .`, and both were failing: 22 files would be reformatted and there were 19 lint errors, all of the latter in the Triton package. Most of this is whitespace. Three fixes are not mechanical: `gemm_probe` built two closures over `a` and `b` in functions whose `finally` deletes both names, so each was correct only because the harness happens to call it before the cleanup runs. They now bind the tensors as default arguments, which captures at definition time and does not depend on call order. This is also what ruff was reporting as F821. `baseline._callable` assigned two lambdas to names, now plain functions. Six imports were unused and are gone. `ScaFFold/viz/standard_viz.py` is not part of this branch's work -- it fails the format check on round2-fixes too, and is reformatted here only so the check can pass. Both suites re-run afterwards, since several tests read their subject's source: triton_conv3d 1425 passed / 16 skipped, ScaFFold 742 passed / 8 skipped / 1 xfailed. Reformatting changes Triton's JIT cache key, so the first run after this recompiles every kernel and takes ~5x longer. * Say which kernel each accelerated module ended up using A run gives no sign of whether the Triton rungs actually served it. Print one line per ladder on rank 0 at startup, naming Triton against everything PyTorch does -- compiled and eager together, since from outside the ladder those are the same answer. Placement is the whole of the problem. ``_triton_ok`` is a latch set when a rung first answers a call, so reporting at construction time would say "Native" about modules that have not run yet. Reporting at the end of warmup is right when there is a warmup, but ``warmup_batches <= 0`` returns before running anything, which is what the benchmark drivers configure -- so a single call site would drop the line on exactly the runs most likely to want it. Both sites call it behind a one-shot flag. The reporter is duck-typed on the latch rather than on isinstance: _rungs is imported by the modules it would otherwise have to import back, and a ladder added later is then reported without touching this code. Labels come from a _rung_label class attribute and fall back to the class name, so a ladder that does not declare one still appears. Deliberately one rank's answer and not a collective: ranks latch independently and a rung can still fall back later, so this is informational rather than a contract, and gathering it would put a barrier on a path with no other reason for one. * Tighten comments and drop measured figures from them Reviewers asked that the comments on this branch be more concise and that specific performance numbers be removed from comments describing changes, since those numbers go stale. Comments and docstrings only: every file's AST is unchanged apart from docstrings, ruff format and check are clean, and the test runner's shell script differs only in comment lines. Gone: timings, speedups, roofline percentages, memory figures, cell tallies, dated history of what the code used to do, and pointers to the untracked scratch tree. Kept: contracts, traps, and the hardware and algorithmic constants the code depends on (buffer-op and int32 limits, tile geometry, tolerances, pinned test shapes). Where a rationale would be empty without its figure, the comment names the bench tool that regenerates it. Across the 30 files: 11.9k comment and docstring lines become 10.6k, and 703 lines carrying a measurement, date, or scratch-tree pointer become 65, each of which is a constraint rather than a measurement. * Install the triton_conv3d package alongside ScaFFold ScaFFold.unet.conv3d imports the sibling package, but package discovery only listed ScaFFold*, so a wheel shipped an adapter with nothing behind it. Discovery is namespace-aware, so the test tree (and the recorded corpora it holds) is excluded by name; the suite runs from a checkout. Verified by building a wheel, installing it into an empty target and importing both packages from there. * Keep the recorded convolution corpora with the tests Nothing on the runtime path reads scaffold_corpus.json or scaffold_census.json; only bench/ and the test suite do, and both run from a checkout. Move them under triton_conv3d/tests/data, point the loaders in shapes.py there, and say what each file is a record of: the corpus joins model-analysis shape dumps with MIOpen profiles of the same configurations, the census is an instrumented training run. * Say why triton_group_norm's activation defaults to None ScaFFold always fuses the ReLU; the default exists because the function is a stand-alone replacement for F.group_norm. * Never take the Triton conv rung without a handler for its failures _triton_kernel_failures() resolved to () when triton.errors could not be imported, and `except ()` catches nothing, so on a Triton without that module every handler in the ladder became a bare try and the first CompilationError or OutOfResources took the rank down -- the inverse of the ladder's contract, silently. Resolve the 2.x roots (CompilationError, OutOfResources) when triton.errors is absent, and have _routing_declines refuse the rung, with one warning, when nothing at all resolved. * Agree the halo exchange across the mesh instead of deciding it per rank Every input to the Triton rung's routing verdict is a fact about one rank -- the thin-shard check in _halo_plan, the device, the layout, the shape gates, the latch -- while the halo exchange is a collective. A rank declining alone went through DistConv's exchange while its neighbour posted receives against this module's, and the mesh hung. Unreachable with the shipped shapes, where D divides evenly, but a hang. The global extent is not on a DCTensor, so the check cannot be derived locally; instead, for any call some rank may exchange on, whether to exchange through this module is the MIN of every rank's verdict, agreed once per module instance and cached. The latch, and any later change in a rank's own verdict, only choose the kernel run on the exchanged tensor, which communicates nothing. A shard that later becomes too thin for a plan on an agreed module is an error rather than a lone trip to MIOpen. Adds the first test of the real exchange: two ranks under torchrun, the sharded output checked against F.conv3d on the whole volume, then a local decline, a latch and a thin shard, each ending with both ranks on the same side of the exchange. * Commit the generator behind scaffold_corpus.json make_corpus.py traces the three profiled configurations with model-analysis/unet_shapes.py, the pure-stdlib shape calculator the corpus already cited as its source, and joins profile_points.json, the MIOpen profile record of the same runs reduced to its convolution kernels. Both were in the untracked scratch tree; the corpus was not regenerable from the repository. `--check` confirms the committed file is reproduced byte for byte. * Commit the instrument and generator behind scaffold_census.json conv_census.py wraps FastConv3d, the two autograd nodes and the six kernel entry points around a real `scaffold benchmark` run and writes one capture per rank; make_census.py folds the four captures under census/ into the committed file. Both were in the untracked scratch tree, where the instrument ran through a scratch training driver. Verified by running the new instrument at configuration A: its capture records the same modules, autograd nodes and kernel calls as the committed one on every field except the resolver's answers on padded backward-weight problems, which the tuning tables have moved since and the census does not read. `--check` confirms the committed census is reproduced from the captures. The census's stale `tuned_declined` field is dropped from the instrument: the decline it described no longer exists. A README in tests/data says what each file is and how to regenerate it.
1 parent c48b7ba commit c6b26f0

47 files changed

Lines changed: 70312 additions & 166 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ScaFFold/unet/_rungs.py‎

Lines changed: 463 additions & 0 deletions
Large diffs are not rendered by default.

‎ScaFFold/unet/conv3d.py‎

Lines changed: 1904 additions & 0 deletions
Large diffs are not rendered by default.

‎ScaFFold/unet/group_norm.py‎

Lines changed: 631 additions & 87 deletions
Large diffs are not rendered by default.

‎ScaFFold/unet/triton_group_norm.py‎

Lines changed: 1607 additions & 0 deletions
Large diffs are not rendered by default.

‎ScaFFold/unet/unet_parts.py‎

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from ScaFFold.utils.perf_measure import annotate
2222

23+
from .conv3d import FastConv3d, FastConvTranspose3d
2324
from .group_norm import FastGroupNorm
2425

2526
_doubleconv_annotate = annotate(fmt="DoubleConv.{}")
@@ -28,30 +29,126 @@
2829
_outconv_annotate = annotate(fmt="OutConv.{}")
2930

3031

31-
def _group_norm(num_groups, num_channels):
32+
def _group_norm(num_groups, num_channels, activation=None):
3233
if num_channels % num_groups != 0:
3334
raise ValueError(
3435
f"group_norm_groups={num_groups} must evenly divide num_channels={num_channels}"
3536
)
36-
# FastGroupNorm is nn.GroupNorm plus a compiled GPU kernel; it holds the
37-
# same parameters under the same names, so checkpoints are unaffected.
38-
return FastGroupNorm(num_groups, num_channels)
37+
# FastGroupNorm is nn.GroupNorm plus a Triton/compiled GPU kernel; it holds
38+
# the same parameters under the same names, and `activation` is a plain
39+
# attribute rather than a submodule, so checkpoints are unaffected.
40+
return FastGroupNorm(num_groups, num_channels, activation=activation)
41+
42+
43+
def _conv3d(in_channels, out_channels, **kwargs):
44+
"""The model's non-transposed convolutions, in one place.
45+
46+
``FastConv3d`` is ``nn.Conv3d`` plus a Triton GPU kernel; it holds the same
47+
parameters under the same names and adds no buffers, so checkpoints are
48+
unaffected in either direction. It falls back to MIOpen for anything the
49+
kernel does not serve, and it serves the sharded configurations too: it
50+
performs the halo exchange itself, above autograd, rather than leaving it to
51+
the one DistConv does below. Only the split axis is halo'd, so ``padding=1``
52+
survives on the other two and these convolutions are padded at every
53+
configuration; see :mod:`ScaFFold.unet.conv3d`.
54+
55+
The ``nn.ConvTranspose3d`` in ``Up`` do not come through here: they are a
56+
different operator, with the weight's channel axes the other way round and a
57+
different set of kernels behind them, so they have a factory of their own
58+
(:func:`_conv_transpose3d`).
59+
"""
60+
return FastConv3d(in_channels, out_channels, **kwargs)
61+
62+
63+
def _conv_transpose3d(in_channels, out_channels, **kwargs):
64+
"""The model's transposed convolutions -- the decoder's upsamplers.
65+
66+
``FastConvTranspose3d`` is ``nn.ConvTranspose3d`` plus a Triton GPU kernel;
67+
it holds the same parameters (``weight`` *and* ``bias``, which these sites
68+
have and the ordinary convolutions mostly do not) under the same names and
69+
adds no buffers, so checkpoints are unaffected in either direction. The
70+
kernel serves only the ``kernel == stride``, no-padding upsample built
71+
below; anything else falls back to MIOpen.
72+
"""
73+
return FastConvTranspose3d(in_channels, out_channels, **kwargs)
74+
75+
76+
def _consumer_dtype(*tensors):
77+
"""The dtype the convolution consuming a concatenation will actually see.
78+
79+
Inside an enabled autocast region the answer is autocast's dtype, because
80+
``aten::convolution`` carries the ``lower_precision_fp`` cast policy and
81+
casts whatever it is handed. Producing that dtype from the concatenation is
82+
*bitwise identical* to producing ATen's promoted dtype and letting the
83+
convolution narrow it -- the promoted tensor holds exact widenings of both
84+
sources, so narrowing before or after the copy rounds the same values once
85+
-- while moving half the bytes.
86+
87+
Outside autocast the answer is ``torch.cat``'s ordinary promotion, so eval,
88+
``inference_mode`` and pure-fp32 runs are unchanged.
89+
"""
90+
dtype = tensors[0].dtype
91+
for tensor in tensors[1:]:
92+
dtype = torch.promote_types(dtype, tensor.dtype)
93+
device_type = tensors[0].device.type
94+
try:
95+
if not torch.is_autocast_enabled(device_type):
96+
return dtype
97+
autocast_dtype = torch.get_autocast_dtype(device_type)
98+
except (RuntimeError, TypeError): # a device type autocast does not know
99+
return dtype
100+
# Only ever narrow: if autocast's dtype is the wider of the two, keep the
101+
# promotion ATen would have done.
102+
if torch.promote_types(autocast_dtype, dtype) is autocast_dtype:
103+
return dtype
104+
return autocast_dtype
105+
106+
107+
def _skip_concat(skip, upsampled):
108+
"""``torch.cat([skip, upsampled], dim=1)`` at the consumer's dtype.
109+
110+
Under ``torch.autocast`` the two halves do not share a dtype: the skip comes
111+
from a GroupNorm, an fp32-policy op, while the upsampled half comes from a
112+
``ConvTranspose3d`` and is bf16. ``torch.cat`` carries the ``promote``
113+
policy, so it widens the bf16 half to fp32, concatenates at fp32, and the
114+
following convolution narrows the whole double-width result straight back
115+
down -- three full-resolution passes to deliver one. Casting the inputs
116+
first collapses that to one, and the convolution reads the same bits either
117+
way (see :func:`_consumer_dtype`).
118+
"""
119+
dtype = _consumer_dtype(skip, upsampled)
120+
return torch.cat([skip.to(dtype), upsampled.to(dtype)], dim=1)
39121

40122

41123
class DoubleConv(nn.Module):
42-
"""(convolution => GroupNorm => ReLU) * 2"""
124+
"""(convolution => GroupNorm => ReLU) * 2
125+
126+
The ReLU lives *inside* the GroupNorm (``activation="relu"``), because the
127+
Triton GroupNorm kernel folds it into its forward store for free and thereby
128+
removes an entire streaming pass. ``FastGroupNorm`` applies the ReLU on
129+
every path, including eager, so the network's function is unchanged; only
130+
the number of memory passes differs.
131+
132+
The ``nn.ReLU`` slots are held open by ``nn.Identity`` rather than removed:
133+
``nn.Sequential`` names its children by position, so deleting them would
134+
renumber the two convolutions and the second GroupNorm and invalidate every
135+
existing checkpoint. Neither ``nn.ReLU`` nor ``nn.Identity`` has parameters
136+
or buffers, so with the placeholders in place the state dict is byte
137+
identical to the pre-fusion model's (pinned by
138+
``tests/test_groupnorm.py::test_state_dict_matches_plain_groupnorm_model``).
139+
"""
43140

44141
def __init__(self, in_channels, out_channels, group_norm_groups, mid_channels=None):
45142
super().__init__()
46143
if not mid_channels:
47144
mid_channels = out_channels
48145
self.double_conv = nn.Sequential(
49-
nn.Conv3d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
50-
_group_norm(group_norm_groups, mid_channels),
51-
nn.ReLU(inplace=True),
52-
nn.Conv3d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
53-
_group_norm(group_norm_groups, out_channels),
54-
nn.ReLU(inplace=True),
146+
_conv3d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
147+
_group_norm(group_norm_groups, mid_channels, activation="relu"),
148+
nn.Identity(),
149+
_conv3d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
150+
_group_norm(group_norm_groups, out_channels, activation="relu"),
151+
nn.Identity(),
55152
)
56153

57154
@_doubleconv_annotate
@@ -75,7 +172,16 @@ def forward(self, x):
75172

76173

77174
class Up(nn.Module):
78-
"""Upscaling then double conv"""
175+
"""Upscaling then double conv
176+
177+
The skip concatenation goes through :func:`_skip_concat` rather than
178+
``torch.cat`` directly, so that it emits the dtype the following
179+
convolution will use instead of ``torch.cat``'s promoted one. The tensor
180+
that convolution reads is bitwise unchanged either way; it is written and
181+
read back at half the width, saving both time and peak memory. This rests
182+
on ``self.conv`` beginning with a convolution, which the constructor below
183+
guarantees on either branch.
184+
"""
79185

80186
def __init__(self, in_channels, out_channels, group_norm_groups, trilinear=True):
81187
super().__init__()
@@ -90,7 +196,7 @@ def __init__(self, in_channels, out_channels, group_norm_groups, trilinear=True)
90196
in_channels // 2,
91197
)
92198
else:
93-
self.up = nn.ConvTranspose3d(
199+
self.up = _conv_transpose3d(
94200
in_channels, in_channels // 2, kernel_size=2, stride=2
95201
)
96202
self.conv = DoubleConv(in_channels, out_channels, group_norm_groups)
@@ -118,14 +224,16 @@ def forward(self, x1, x2):
118224
# if you have padding issues, see
119225
# https://github.com/HaiyongJiang/U-Net-Pytorch-Unstructured-Buggy/commit/0e854509c2cea854e247a9c615f175f76fbb2e3a
120226
# https://github.com/xiaopeng-liao/Pytorch-UNet/commit/8ebac70e633bac59fc22bb5195e513d5832fb3bd
121-
x = torch.cat([x2, x1], dim=1)
227+
# torch.cat([x2, x1], dim=1) at the dtype the convolution below
228+
# actually wants; see the class docstring.
229+
x = _skip_concat(x2, x1)
122230
return self.conv(x)
123231

124232

125233
class OutConv(nn.Module):
126234
def __init__(self, in_channels, out_channels):
127235
super(OutConv, self).__init__()
128-
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size=1)
236+
self.conv = _conv3d(in_channels, out_channels, kernel_size=1)
129237

130238
@_outconv_annotate
131239
def forward(self, x):

‎ScaFFold/utils/trainer.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from torch.utils.data import DataLoader
3030
from tqdm import tqdm
3131

32+
from ScaFFold.unet._rungs import format_kernel_selection, kernel_selection
3233
from ScaFFold.utils.checkpointing import CheckpointManager
3334
from ScaFFold.utils.data_loading import FractalDataset, SpatialShardSpec
3435
from ScaFFold.utils.data_types import AMP_DTYPE, VOLUME_TORCH_DTYPE
@@ -90,6 +91,9 @@ class BaseTrainer:
9091

9192
def __init__(self, model, config, device, log):
9293
self.model = model
94+
# One-shot guard for the startup kernel-selection line; see
95+
# _log_kernel_selection for why it has two call sites.
96+
self._kernel_selection_logged = False
9397
self.config = config
9498
self.device = device
9599
self.log = log
@@ -786,6 +790,30 @@ def _warmup_ragged_batches(self, batch):
786790
log_prefix=f"warmup ragged ({ragged}): ",
787791
)
788792

793+
def _log_kernel_selection(self):
794+
"""Log which kernel each accelerated module is using, once, on rank 0.
795+
796+
Called from two places -- the end of :meth:`warmup` and after the first
797+
training batch -- because the answer only exists once a forward has run:
798+
``_triton_ok`` is a latch set when a rung first answers a call, so
799+
reporting at construction time would say "Native" about modules that
800+
have simply not run yet. ``warmup_batches <= 0`` makes :meth:`warmup`
801+
return without running anything, so neither call site alone covers every
802+
run; the flag below makes the pair idempotent.
803+
804+
Rank 0 only, and one rank's answer: each rank latches independently, so
805+
under DDP this is representative rather than global. Deliberately not a
806+
collective -- gathering it would put a barrier on a path that has no
807+
other reason for one.
808+
"""
809+
if self._kernel_selection_logged or self.world_rank != 0:
810+
return
811+
self._kernel_selection_logged = True
812+
model = getattr(self.model, "module", self.model)
813+
self.log.info("Kernel selection (rank 0):")
814+
for line in format_kernel_selection(kernel_selection(model)):
815+
self.log.info(line)
816+
789817
def warmup(self):
790818
"""Run warmup iterations before the main training loop."""
791819
warmup_batches = self.config.warmup_batches
@@ -847,6 +875,7 @@ def warmup(self):
847875

848876
torch.distributed.barrier()
849877
self.log.info(f"Done warmup. Took {int(time.time() - start_warmup)}s")
878+
self._log_kernel_selection()
850879

851880
def train(self, profiler=None):
852881
"""
@@ -953,6 +982,8 @@ def train(self, profiler=None):
953982
# not skew the epoch mean.
954983
train_dice_total += batch_dice_score * batch_size
955984
end_code_region("run_training_batch")
985+
if first_batch:
986+
self._log_kernel_selection()
956987

957988
# Update the loss
958989
begin_code_region("update_loss")

‎ScaFFold/viz/standard_viz.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ def main(config: RunConfig):
4949
if "val_loss_avg" in row:
5050
val_loss.append(float(row["val_loss_avg"]))
5151

52-
plot_title = f"v={config.vol_size}, c={config.n_categories}, u={config.unet_layers}"
52+
plot_title = (
53+
f"v={config.vol_size}, c={config.n_categories}, u={config.unet_layers}"
54+
)
5355
line_thickness = 2
5456
fontsize = 20
5557
tick_fontsize = 14
@@ -64,7 +66,9 @@ def main(config: RunConfig):
6466
plt.tick_params(axis="both", which="major", labelsize=tick_fontsize)
6567
plt.yscale("log")
6668
plt.title(plot_title, fontsize=12)
67-
plt.legend(loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize)
69+
plt.legend(
70+
loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize
71+
)
6872
plt.grid(True, axis="y")
6973
plt.savefig(figures_path / "train_loss.png", dpi=300, bbox_inches="tight")
7074
plt.close(figures[-1])
@@ -76,7 +80,9 @@ def main(config: RunConfig):
7680
plt.ylabel("Val dice score", fontsize=fontsize)
7781
plt.tick_params(axis="both", which="major", labelsize=tick_fontsize)
7882
plt.title(plot_title, fontsize=12)
79-
plt.legend(loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize)
83+
plt.legend(
84+
loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize
85+
)
8086
plt.grid(True, axis="y")
8187
plt.savefig(figures_path / "val_dice.png", dpi=300, bbox_inches="tight")
8288
plt.close(figures[-1])
@@ -89,7 +95,9 @@ def main(config: RunConfig):
8995
plt.ylabel("Val loss", fontsize=fontsize)
9096
plt.tick_params(axis="both", which="major", labelsize=tick_fontsize)
9197
plt.title(plot_title, fontsize=12)
92-
plt.legend(loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize)
98+
plt.legend(
99+
loc="upper left", bbox_to_anchor=legend_loc, fontsize=legend_fontsize
100+
)
93101
plt.grid(True, axis="y")
94102
plt.savefig(figures_path / "val_loss.png", dpi=300, bbox_inches="tight")
95103
plt.close(figures[-1])

0 commit comments

Comments
 (0)