<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charles-Antoine Fournel</title>
    <description>The latest articles on DEV Community by Charles-Antoine Fournel (@cfournel).</description>
    <link>https://dev.to/cfournel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4137573%2Fac45e719-e371-460d-ac7a-93c7388b0718.jpg</url>
      <title>DEV Community: Charles-Antoine Fournel</title>
      <link>https://dev.to/cfournel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cfournel"/>
    <language>en</language>
    <item>
      <title>Memory, not speed, is the hard part of running an LLM on a phone</title>
      <dc:creator>Charles-Antoine Fournel</dc:creator>
      <pubDate>Tue, 22 Sep 2026 12:12:35 +0000</pubDate>
      <link>https://dev.to/cfournel/memory-not-speed-is-the-hard-part-of-running-an-llm-on-a-phone-53mk</link>
      <guid>https://dev.to/cfournel/memory-not-speed-is-the-hard-part-of-running-an-llm-on-a-phone-53mk</guid>
      <description>&lt;p&gt;I ship an Android app (Onira) that generates a personalized hypnosis/relaxation&lt;br&gt;
script on-device with Gemma 4 E2B, through LiteRT-LM, then narrates it with&lt;br&gt;
on-device TTS. Nothing the user types, and nothing the model generates, ever&lt;br&gt;
leaves the phone. This is the part that was actually hard to get right, and it&lt;br&gt;
was not the part I expected.&lt;/p&gt;
&lt;h2&gt;
  
  
  Speed was fine. Memory nearly killed it.
&lt;/h2&gt;

&lt;p&gt;A mid-range phone writes 300-500 words in tens of seconds — acceptable for a&lt;br&gt;
relaxation app where narration masks generation latency. The real problem: the&lt;br&gt;
runtime loads the 2.6GB model into the native heap and does not mmap it.&lt;br&gt;
Measured on a Pixel 7 (7.6GB RAM), mid-generation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~6GB PSS&lt;/li&gt;
&lt;li&gt;zram 100% full&lt;/li&gt;
&lt;li&gt;kswapd0 at 72% CPU&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The process thrashes, generation crawls, and Android's low-memory killer takes&lt;br&gt;
the app the instant it leaves the foreground. On a 6GB device it's worse.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix #1: stop holding the model once you're done needing it
&lt;/h2&gt;

&lt;p&gt;The obvious mistake would be keeping the &lt;code&gt;Engine&lt;/code&gt;/&lt;code&gt;Conversation&lt;/code&gt; alive for the&lt;br&gt;
whole session "just in case." But my output is consumed by TTS over the next&lt;br&gt;
30-40 minutes, and for all of that time nothing needs the model resident. So:&lt;br&gt;
close it the instant the last block is generated, and let the rest of the&lt;br&gt;
session narrate with the model unloaded. That window is also, not&lt;br&gt;
coincidentally, exactly when a user is most likely to background the app to do&lt;br&gt;
something else — which is exactly when the low-memory killer was taking it.&lt;/p&gt;

&lt;p&gt;The one deliberate exception: a &lt;code&gt;mediaPlayback&lt;/code&gt; foreground service keeps the&lt;br&gt;
process alive for the whole narration, started right when the user taps&lt;br&gt;
"Begin Session." Without it, backgrounding the app during the first few&lt;br&gt;
generating minutes (model still resident) got the whole session killed&lt;br&gt;
outright. Android 12+ also refuses a foreground-service start from the&lt;br&gt;
background — exactly the moment that request usually comes — so it has to&lt;br&gt;
start earlier than you'd want, while the model is still loaded. The accepted&lt;br&gt;
cost: the device may thrash and lmkd kills &lt;em&gt;other&lt;/em&gt; background apps instead,&lt;br&gt;
until the model unloads a few minutes in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix #2: generate in blocks, not one call
&lt;/h2&gt;

&lt;p&gt;A session is an ordered sequence of blocks — induction, deepening, metaphor,&lt;br&gt;
suggestions, anchoring, repeat, emergence. Each is generated as its own turn&lt;br&gt;
on the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;Engine&lt;/code&gt;/&lt;code&gt;Conversation&lt;/code&gt;, so later blocks stay thematically&lt;br&gt;
consistent with earlier ones without re-stating prior text in the prompt.&lt;br&gt;
Two consequences that matter more than the consistency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playback starts before the model finishes anything.&lt;/strong&gt; Induction and
emergence are static templates — neither depends on the category or the
user's free text, so there's nothing to personalize — which means narration
can start as soon as the model is &lt;em&gt;loaded&lt;/em&gt;, without waiting on a single
generation call. On a mid-range device the first real generation can take
minutes; a user hearing a voice at second three is a different product than
one staring at a spinner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure is local, not fatal.&lt;/strong&gt; A block that fails generation gets a short
pre-written bridging line instead of aborting the session. The listener
never notices; they just got a slightly shorter passage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The body is capped at 6 blocks — a repeating cycle of&lt;br&gt;
&lt;code&gt;[deepening, metaphor, suggestions, anchoring]&lt;/code&gt;. This bound is what keeps any&lt;br&gt;
single generation call short regardless of total session length, and it's&lt;br&gt;
also exactly what the context window is sized against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MAX_BODY_BLOCKS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
&lt;span class="nc"&gt;When&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt; &lt;span class="n"&gt;budget&lt;/span&gt; &lt;span class="n"&gt;doesn&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="n"&gt;fill&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="n"&gt;cycle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt; &lt;span class="n"&gt;keeps&lt;/span&gt;
&lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;highest-priority&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt; &lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;drops&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;suggestions&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt;
&lt;span class="n"&gt;anchoring&lt;/span&gt; &lt;span class="n"&gt;survive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metaphor&lt;/span&gt; &lt;span class="n"&gt;goes&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;buildBodyPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetBodyWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SessionBlockType&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bodyBlocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;targetBodyWords&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;WORDS_PER_BLOCK_TARGET&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nc"&gt;WORDS_PER_BLOCK_TARGET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coerceIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MAX_BODY_BLOCKS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;buildList&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bodyBlocks&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nc"&gt;CORE_CYCLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CORE_CYCLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;partialCycle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bodyBlocks&lt;/span&gt; &lt;span class="p"&gt;%&lt;/span&gt; &lt;span class="nc"&gt;CORE_CYCLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounded to the nearest block, not floored — at the short end of the range,&lt;br&gt;
flooring to whole cycles costs four blocks at once and undershoots the target by far more than rounding up overshoots it.&lt;/p&gt;

&lt;p&gt;The thing I got wrong for a while: sizing by word count&lt;br&gt;
My first version budgeted a fixed word count for the whole script — "4200&lt;br&gt;
words ≈ 30 minutes at 140 wpm." It shipped ~90-minute sessions at default&lt;br&gt;
settings. The bug: narration speed and the user's "pause between phrases"&lt;br&gt;
setting (3-15s, applied at every clause boundary) swing real spoken duration&lt;br&gt;
by more than 3x. A fixed word count cannot hold duration steady when the&lt;br&gt;
seconds-per-word ratio itself varies that much per user.&lt;/p&gt;

&lt;p&gt;The fix budgets from a target duration, converted to words using the user's&lt;br&gt;
own playback settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;secondsPerWord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speechRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentencePauseSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;userScale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speechRate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coerceAtLeast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nc"&gt;REFERENCE_USER_RATE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDouble&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;wordsPerMinute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BASE_WORDS_PER_MINUTE&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;NARRATION_BASE_RATE&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;userScale&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;speakingSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;wordsPerMinute&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pause&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sentencePauseSeconds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coerceIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;15f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pausePerClause&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SENTENCE_BOUNDARY_SHARE&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pause&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;SENTENCE_BOUNDARY_SHARE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;NON_SENTENCE_PAUSE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;userScale&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;speakingSeconds&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pausePerClause&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nc"&gt;AVG_WORDS_PER_CLAUSE&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;wordsForSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;speechRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentencePauseSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Float&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetSeconds&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;secondsPerWord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;speechRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentencePauseSeconds&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toInt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;coerceAtLeast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AVG_WORDS_PER_CLAUSE and SENTENCE_BOUNDARY_SHARE aren't guesses — they're&lt;br&gt;
measured off the shipped template corpus (~10k words): one clause boundary&lt;br&gt;
every ~17 words, ~85% of them sentence ends that take the user's pause&lt;br&gt;
setting; the rest (ellipsis, semicolon) stay on a fixed, shorter pause. Only&lt;br&gt;
sentence boundaries scale with the user's setting, because that's the only&lt;br&gt;
pause the setting is actually supposed to control.&lt;/p&gt;

&lt;p&gt;The result: total session length is the thing held constant across every&lt;br&gt;
speech-rate and pause-length combination a user can pick, instead of word&lt;br&gt;
count — which is what actually matters for a relaxation session that's&lt;br&gt;
supposed to run 30-40 minutes, not 90.&lt;/p&gt;

&lt;p&gt;The honest costs of on-device&lt;br&gt;
A 2.6GB first-run model download — by a wide margin the largest drop-off in&lt;br&gt;
the funnel.&lt;br&gt;
No server-side moderation layer in front of a model writing&lt;br&gt;
psychologically-framed text for someone who typed in whatever's actually&lt;br&gt;
on their mind. Handled with a deliberately high-recall keyword gate that&lt;br&gt;
runs before the model is ever invoked — false positives (blocking a&lt;br&gt;
benign message) are cheap here; false negatives are not.&lt;br&gt;
Happy to go deeper on the safety gate design or the LiteRT-LM integration&lt;br&gt;
specifically, if there's interest. The app is &lt;a href="https://play.google.com/store/apps/details?id=com.oytaub.mindease" rel="noopener noreferrer"&gt;Onira&lt;/a&gt;&lt;br&gt;
on the Play Store if anyone wants to see the output — but the above is the&lt;br&gt;
part I think is worth discussing.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
