Skip to content

dtoctl: add --stats-run to measure the speedup from DSA (stacked on #38, #41) - #42

Open
byrnedj wants to merge 10 commits into
intel:mainfrom
byrnedj:stats-run
Open

byrnedj wants to merge 10 commits into
intel:mainfrom
byrnedj:stats-run

Conversation

@byrnedj

@byrnedj byrnedj commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Stacked on #38 (dtoctl) and #41 (stats). Only the last commit, dtoctl: add --stats-run to measure the speedup from DSA, is new here. I'll rebase once those are merged.

dtoctl --stats-run answers two questions: how much faster is a program with DSA, and how much faster could it be? It runs the program CPU-only (DTO_USESTDC_CALLS=1) and then with DSA, collecting #41's JSON stats from each run. dto-stats-report then compares the runs using Amdahl's law. With --perf, it also profiles one run each way and shows where the CPU time went.

sudo dtoctl -w busypoll --stats-run=app-stats --repeat=3 --perf ./app 1 20000 262144 65536 20000
DTO stats run: app-stats
  command: ./app 1 20000 262144 65536 20000
  settings: DTO_WAIT_METHOD=busypoll
  timed runs: 3 CPU-only, 3 with DSA (medians)
  DTO in the DSA run: 1 WQs, wait busypoll, min_bytes 65536, cpu_size_fraction 0.5 (auto-adjusted)

                                     CPU only       with DSA   speedup
  wall time                         972.66 ms         1.55 s     0.63x
  CPU time (user+sys)               968.31 ms         1.54 s     0.63x
    in mem* calls                   304.86 ms      794.05 ms     0.38x
    outside mem* calls              663.45 ms      747.81 ms     0.89x
  wall time, min-max          972.24-973.64 ms    1.26-1.58 s

  Amdahl's law
    f, share of CPU time in mem* calls (CPU-only)     31.5%
    s, mem* speedup with DSA (time per byte)          0.38x
    predicted speedup, 1 / ((1 - f) + f / s)          0.66x
    measured speedup, wall time                       0.63x
    limit if mem* took no time, 1 / (1 - f)           1.46x

  per operation       calls       bytes   avg size   CPU only   with DSA  speedup  on DSA       f
  memcpy             20.00k     4.9 GiB  256.0 KiB  115.50 ms  266.45 ms    0.43x  100.0%   11.9%
  memset             20.00k     4.9 GiB  256.0 KiB   96.36 ms   83.32 ms    1.16x  100.0%   10.0%
  memcmp             20.00k     4.9 GiB  256.0 KiB   93.00 ms  444.27 ms    0.21x  100.0%    9.6%
    (on DSA: share of the bytes in calls sent to DSA, of which the CPU does cpu_size_fraction;
     f: share of the CPU-only run's CPU time)
    calls DSA left to the CPU, per run: page fault 1, retry 0, other 0

  by size (most time)       calls   CPU only   with DSA  speedup  on DSA
  256K-320K                60.00k    5.08 us   13.23 us    0.38x  100.0%
    (average time per call; on DSA: share of calls sent to DSA)

  profile (perf, % of CPU time)        CPU only   with DSA
    memcpy/memmove                        13.0%      12.2%
    memset                                10.0%       1.6%
    memcmp                                10.2%       0.0%
    CRC                                   14.6%      11.7%
    DTO library                            0.0%      34.4%
    libc, unknown function                 0.0%       0.1%
    other user code                       51.5%      39.6%
    other kernel code                      0.7%       0.5%

  note: mem* calls that went to DSA were slower than on the CPU at every size this program used
  note: 15% of the CPU-only run is CRC code: DSA can compute CRC32C, but DTO doesn't intercept CRC functions, so offloading it needs an explicit call

This is gnr-qual1 with one DSA (wq0.0). The test program is single-threaded: it copies, sets and compares 256 KiB buffers that stay in cache, computes a software CRC32C, and does other work. The CPU-only run spent f = 31.5% of its CPU time in mem* calls, and those calls ran at s = 0.38x with DSA. Amdahl's law therefore predicts 0.66x, and the measured speedup was 0.63x. The remaining gap shows up on its own row: time outside mem* calls went from 663 to 748 ms. The profile shows the cost of DSA here: 34% of the DSA run's CPU time is DTO busy-polling.

What it does

  • dtoctl creates DIR, which must be new or empty; the default is dto-stats-<date>-<time>. It runs the program N times each way (--repeat), alternating the two modes so that drift affects both. For each run it records wall time, user+sys CPU time (wait4, so children are included) and the exit status in runs.jsonl, then runs dto-stats-report DIR.
    • Ctrl-C and Ctrl-\ go to the program, as with system(). A run that fails stops the rest, and dtoctl exits with its status.
    • --dry-run prints the plan without running anything.
  • --perf[=PERF] adds one more run each way under PERF record -e cpu-clock -F 999.
    • cpu-clock needs no hardware counters. On gnr-qual1's 7.2 kernel, cycles gave about 10 samples a run.
    • perf runs dtoctl again (perf record -- dtoctl -l LIB -- prog), so DTO is preloaded into the program but not into perf.
  • dto-stats-report is Python 3 using only the standard library; --json prints the numbers instead of the tables. It:
    • sums the stats of every process a run started and takes medians over the runs;
    • compares speed per byte, so a benchmark that runs for a fixed time still compares correctly;
    • lists each operation and the sizes with the most time.
  • The report's notes cover:
    • no call reaching DSA, with the reason: no usable WQ (stdc_only at exit), or every call below min_bytes;
    • runs that did different amounts of work;
    • sizes where DSA lost, and a DTO_MIN_BYTES to try when there is a clear crossover;
    • runs that failed, or wrote no stats or no summary (a library too old for JSON stats, a static binary, a program that leaves with _exit() as dash does);
    • from the profile: mem* time DTO didn't intercept, CRC time DSA could take, and kernel copies.
  • libc without symbols. Ubuntu's libc has no .symtab, so perf shows __memmove_avx512_unaligned_erms as a bare address. On the machine that made the profile, the report uses dlsym in its own process to find which memcpy/memset/memcmp implementations this CPU uses. It then places samples by address, but only after checking the profile's libc has the same build ID. Without this, the libc rows of the profile above would be unnamed.
  • New options --stats-format and --stats-interval set DTO_STATS_FORMAT and DTO_STATS_INTERVAL_MS.

Limits

  • Amdahl's estimate uses CPU time, so it assumes the program keeps its CPUs busy. A program that waits for I/O gains less in wall time; the README and man page say so.
  • DTO writes stats files readable by their owner only (as on main), so results from a sudo run need sudo dto-stats-report. The report says this instead of failing with a traceback.

Testing

  • ctest -R "dtoctl|stats|functional" passes:
    • locally on RHEL 8 (glibc 2.28);
    • on gnr-qual1 (Ubuntu 24.04, glibc 2.39), as a user (CPU fallback) and as root with dsa0.
  • tests/test_dtoctl.sh checks:
    • the new options and the conflicting combinations;
    • the --dry-run plan;
    • a real stats run of a shell command: each run's environment, runs.jsonl, the saved stats and the report;
    • that a failing run stops the stats run with its status, and that a directory with results in it is refused.
  • tests/test_stats_report.py (new stats-report ctest; needs neither DSA nor perf) checks:
    • the report's arithmetic on made-up runs with known answers: f, s, predicted, limit, medians, per-op and per-size numbers, and the notes;
    • sorting of made-up perf lines into categories;
    • finding memmove/memset/memcmp by address in the local libc, which passes on both glibc 2.28 and 2.39.
  • By hand on gnr-qual1:
    • the full run above with DSA and perf;
    • SIGINT to the process group (the program dies, dtoctl exits 130 after recording the signal);
    • a program that forks (both processes counted);
    • a run against an older installed libdto (the report says it is too old for JSON stats).

Running a stats run on RHEL 8 turned up a bug in #41: libdto wasn't linked with pthread. The fix is now on #41 (stats: link libdto with pthread) and included here.

honggyukim and others added 10 commits October 21, 2026 07:24
Using the DTO library today requires the user to understand two orthogonal
mechanisms before a single program can be offloaded to DSA.  The library has
to be injected into the target through LD_PRELOAD, and its behavior is steered
entirely through a set of roughly twenty DTO_* environment variables that must
be discovered by reading the manual and then exported by hand.  This is easy to
get wrong: the LD_PRELOAD path and the variable names are stringly typed, an
exported variable silently leaks into every later command in the same shell,
and there is no validation or discoverability at the point of use.

Provide a small wrapper, dtoctl, that turns this into a single self-describing
command line.  It follows the same model as hmctl from the hmsdk project:
parse options, translate them into the environment the library expects, and
then exec the target program so no state lingers in the caller's shell.  On top
of that model dtoctl also takes care of the injection itself by prepending the
resolved libdto.so to LD_PRELOAD, which is possible because DTO works by
transparently interposing on the standard memory APIs.

Every DTO_* variable is exposed as an option so the mapping stays obvious and
greppable.  Only options that are actually given are written to the child
environment; anything left unset keeps whatever the caller already exported or
the library's own default, and an explicit option wins over an inherited
variable.  The library path is resolved from -l/--library, then $DTO_LIBRARY,
then a compiled-in default, and execvp() is used so a bare command name on
PATH works as well as an explicit path.

Build and install dtoctl with both Make and CMake.  Set its compiled-in
library path to match the installation directory, since CMake's default
/usr/local/lib differs from the Makefile's /usr/lib64.  Allow that path to be
overridden with DTOCTL_LIBPATH.

With this in place the previous incantation

  $ export LD_PRELOAD=/usr/lib64/libdto.so
  $ export DTO_WAIT_METHOD=busypoll
  $ export DTO_CPU_SIZE_FRACTION=0.33
  $ ./prog

collapses to

  $ dtoctl -w busypoll -c 0.33 ./prog

Signed-off-by: Honggyu Kim <honggyu.kim@sk.com>
The dtoctl options mirror the DTO_* environment variables, but a user coming to
the tool for the first time still needs a single authoritative place that
explains what each option does, what values it accepts, and how the library
path and LD_PRELOAD are resolved.  Scattering that information across the source
help text alone makes it hard to consult without running the tool.

Add a manual page in the same troff-friendly markdown form the hmsdk project
uses for hmctl, and render it as a section 1 man page for the user command.
Besides the per-option reference it documents the library resolution order and
the override semantics, and it gathers the common tuning goals - latency, power,
and cache-pollution avoidance - as ready-to-run examples such as

  $ dtoctl -w umwait -c 0.0 --no-auto-adjust ./prog

so users can map an intent directly onto a command instead of reverse
engineering it from the individual variables.

Keep the markdown as the source form and generate dtoctl.1 with pandoc during
the Makefile and CMake builds.  Both build systems install the generated page
under man1, so it can be read with

  $ man 1 dtoctl

Pandoc is optional: when it is unavailable, print a message recommending its
installation and skip manual generation.  The Makefile can still install an
already generated page.  Ignore its generated doc/dtoctl.1 and remove it on
make clean; CMake keeps its generated page in the build directory.

Signed-off-by: Honggyu Kim <honggyu.kim@sk.com>
The README is where users first learn how to configure and run DTO, and until
now it only described the manual LD_PRELOAD and DTO_* export workflow.  A reader
following it has no reason to know that dtoctl exists or that it removes most of
that ceremony, so the easier path stays hidden precisely where people look for
guidance.

Introduce dtoctl after the environment variable reference and the common usage
models that accompany it, so that discussion stays in one piece and the new
section can point back at those usage models.  Contrast the old export sequence
with the equivalent

  $ dtoctl -w busypoll -c 0.33 ./prog

and spell out the resolution order for libdto.so along with the rule that only
the options actually passed are applied.

Keep the per-option reference in dtoctl(1) instead of repeating it here.  The
option list already exists in the --help output and in the manual, and a third
copy in the README would only be one more place to forget when an option
changes.

Signed-off-by: Honggyu Kim <honggyu.kim@sk.com>
The yield wait method is implemented in dsa_wait() and documented in
the README, but dsa_init() never matched "yield" when parsing
DTO_WAIT_METHOD, so DTO_WAIT_METHOD=yield silently ran with busypoll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
Make the wrapper catch the mistakes it exists to prevent instead of
passing them on to libdto, which silently falls back to a default:

- Validate every option value: wait method names, integer ranges for
  --numa-aware and --log-level, a cpu fraction in [0, 1), a positive
  umwait delay, and non-empty paths and WQ list.  --min-bytes takes an
  optional K/M/G suffix and is passed on as plain bytes; libdto would
  read "64K" as 64 and "abc" as 0.
- Resolve libdto.so to an absolute path and fail with exit status 125
  if it cannot be read.  ld.so only warns about a missing preload and
  runs the program without DTO, and a relative path stopped working as
  soon as the program changed directory.  When built with
  LIBDTO_REL_PATH, prefer the library installed next to dtoctl so a
  relocated install finds its own copy.
- Drop other libdto.so entries from an inherited LD_PRELOAD, so one
  process never loads two copies of DTO.
- --stats-file implies --stats; libdto ignores DTO_STATS_FILE otherwise.
- Add -i/--ignore-env to unset inherited DTO_* variables.  The --no-*
  flags can only turn features off, so this is the way to undo an
  exported DTO_USESTDC_CALLS=1 or DTO_DSA_MEMCPY=0.
- Add --dry-run to print the resolved library, LD_PRELOAD, each DTO_*
  variable with whether it came from an option or the environment, and
  the command, without running it.
- Add --version, and exit with 126/127 when the program cannot be run
  or is not found, as env(1) and timeout(1) do.
- Make the options local to main() and drop dead argument handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
CMake cached the default library path on the first configure, so a
later CMAKE_INSTALL_PREFIX change was ignored, and "cmake --install
--prefix" produced a dtoctl that preloaded /usr/local/lib/libdto.so.
Compute the default on every configure, pass dtoctl the library's path
relative to its own bindir so a relocated install finds its own copy,
and keep DTOCTL_LIBPATH as an explicit override.

Pass the project version to both builds for --version, and make the
Makefile's install target build dtoctl first.

Add tests/test_dtoctl.sh as the "dtoctl" CTest (label functional).  It
needs no DSA: it checks the option to variable mapping, validation,
library resolution, LD_PRELOAD handling and exit statuses through
--dry-run and the program's environment, and that libdto applies the
settings through its own configuration trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
Update the dtoctl man page and README section for the new behavior:
option validation, -i/--ignore-env, --dry-run, --version, size
suffixes, --stats-file implying --stats, how the library is resolved,
and the exit statuses.  Add a note on sudo, which resets the
environment and strips LD_PRELOAD when it sits between dtoctl and the
program, and on settings being inherited by child processes.

Fix stale defaults in the README's variable list: DTO_WAIT_METHOD
defaults to busypoll and DTO_MIN_BYTES to 65536, tpause is a valid
wait method, and DTO_LOG_FILE gets .<program>.<pid> appended.  Describe
the dtoctl test in TESTING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
DTO_COLLECT_STATS kept one global table that every thread updated with
atomic adds, so the cost of collecting grew with the thread count: a
4 KiB memcpy on the CPU path cost 55 ns more with stats on at one
thread and 2.6 us more at 32 threads. Each thread now counts in its own
block, mapped when the thread first records a call and folded into a
retired total when the thread exits; output sums the live blocks and
the retired total. Stats now cost about 40 ns per call (the two clock
reads) at any thread count:

  extra cost per call, CPU path   1 thr   4 thr   16 thr  32 thr
  4 KiB, before                   +55     +357    +1319   +2612 ns
  4 KiB, after                    +44     +46     +42     +37 ns

The histogram's 512 linear 4 KiB buckets, the last of which held
everything from 2 MiB up, become log-scaled: under 64 bytes, then four
buckets per power of two up to 4 GiB, then one for anything larger.

New settings:
  DTO_STATS_INTERVAL_MS=N  a sampler thread writes what happened in
                           each N ms; the records add up to the summary
  DTO_STATS_FORMAT=json    JSON Lines: interval and summary records with
                           calls/bytes/ns per op and path, failures,
                           histogram, and the configuration at exit
  DTO_STATS_FILE=...%p...  %p becomes the process ID

DTO_STATS_FILE is now appended to rather than truncated, so a forked
or exec'd child's records don't overwrite its parent's; each record
carries its pid. After fork the child starts with empty counts, and a
%p file is reopened under its own pid. The old dto_stats_log() wrote
each line twice when DTO_LOG_FILE was set and reused a consumed
va_list; it is gone, along with the TRACE log level that turning on
stats forced.

Adds tests/test_stats.py (ctest -R stats) with a workload of known
calls: totals and buckets, intervals summing to totals, 200
short-lived threads all counted and their blocks unmapped, fork,
%p, and text output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
The stats rework uses thread keys, a sampler thread and its condition
variable. Before glibc 2.34 those functions are in libpthread, not libc,
so preloading libdto into a program that doesn't link libpthread itself
(sh, true, most single-threaded tools) failed at load time:

  true: symbol lookup error: libdto.so: undefined symbol: pthread_key_create

Link libdto with pthread in CMake and the Makefile (an empty stub on
newer glibc), and check in the stats test that stats work in a program
without libpthread. Seen on RHEL 8 (glibc 2.28); Ubuntu 24.04 (2.39)
is not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
"dtoctl --stats-run[=DIR] [--repeat=N] [--perf[=PERF]] prog" runs the
program CPU-only (DTO_USESTDC_CALLS=1) and then with DSA, N times each,
alternating, with DTO_STATS_FORMAT=json and each run's stats in its
own file in DIR. dtoctl records each run's wall time, user and system
CPU time (wait4, so including children) and exit status in
DIR/runs.jsonl, then runs dto-stats-report on DIR.

dto-stats-report (Python 3, standard library only) sums the stats of
every process a run started, takes medians over the runs, and prints:
wall, CPU and mem* time each way; Amdahl's law from f (share of the
CPU-only run's CPU time in mem* calls) and s (mem* speedup per byte),
1 / ((1 - f) + f / s), against the measured speedup and the 1 / (1 - f)
limit; each operation and the sizes with the most time; and notes:
no call reached DSA (and why), runs did different amounts of work,
sizes where DSA was slower (with a DTO_MIN_BYTES to try), runs that
failed or wrote no stats. --json prints the numbers instead.

With --perf, one more run each way goes through
"PERF record -e cpu-clock" (cpu-clock works without hardware counters,
and on a test kernel here cycles hardly sampled). perf runs dtoctl
again, so DTO is preloaded into the program and not into perf. The
report splits each profile into copies, memset, memcmp, CRC, kernel
copies, DTO itself and the rest, and notes mem* time DTO did not
intercept and CRC time DSA could take. Where libc has no symbol table
(Ubuntu), perf shows its memcpy as an address; on the machine that
made the profile the report asks its own libc, through dlsym, which
implementation of each function this CPU uses, and places samples by
address, after checking the profile's libc has the same build ID.

Ctrl-C and Ctrl-\ go to the program, as with system(); a run that
fails stops the rest, and dtoctl exits with its status.

Also adds --stats-format and --stats-interval for DTO_STATS_FORMAT and
DTO_STATS_INTERVAL_MS, and documents them with --stats-run in the man
page, README and TESTING.md.

Tests: test_dtoctl.sh checks the new options, the --stats-run plan
(--dry-run), a real stats run of a shell command and a failing one;
tests/test_stats_report.py checks the report's arithmetic on made-up
runs with known answers, sorts made-up perf lines, and finds memcpy,
memset and memcmp by address in this machine's libc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mv7xKtQPkGoXQUvvuuhAEz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants